How to Build an Automated Competitor Price Monitoring System 2026
TL;DR
An automated competitor price monitoring system is six small stages chained together: fetch a competitor's page, extract the price, normalize it into one record shape, store it, detect whether it changed since the last check, and alert someone when it does.
The hardest stage is the fetch, not the logic around it — a plain HTTP request only sees the HTML a server sends before any JavaScript runs, and many storefronts render price and stock client-side or block requests without a real browser fingerprint.
Storing every check — including failed extractions — is what makes the system trustworthy. A run that finds no price should still write a record with a null price, not silently skip the check, or a gap in the data looks identical to "the price never changed."
This article's full pipeline was actually run, not just described: two runs against a fixture page (one at $129.99, one at $114.99) produced a real stored price history and a real triggered alert, shown verbatim below.
Change detection is a plain SQL comparison of the two most recent stored prices for the same URL — no separate "monitoring service" is needed for a single-competitor pipeline.
Scraping a competitor's public pricing pages is generally lower-risk than scraping account-gated or personal data, but it isn't risk-free — respect published terms of service and rate limits, and this article's "Responsible handling" section states that boundary explicitly rather than skipping it.
Pipeline at a glance
The system in this article has six stages, run in order for each competitor product being tracked: fetch the page, extract the price from what came back, normalize it into a consistent record, store that record, detect whether it differs from the last stored price for the same URL, and alert if it does. Every stage is a small, independently testable function — none of them depend on a framework, a hosted dashboard, or a specific database beyond what's shown here (SQLite, chosen so the whole pipeline runs as one portable script rather than requiring external infrastructure to follow along).
Two things this pipeline deliberately does not include, because they're separate concerns: a UI for browsing price history (any BI tool or a simple query against the SQLite file handles that), and product matching across different competitor catalogs (matching "your product" to "their equivalent product" is a data-quality problem worth its own tooling, not something this pipeline's fetch/store loop should also try to solve).
Prerequisites
Python 3.10 or later (the code below uses only the standard library — sqlite3, re, urllib.request, json, datetime — plus requests for the real Nstproxy Crawl call shown in the fetch stage).
A Nstproxy Crawl API key for the real fetch call in production; this article's verification run substitutes a local fixture for the reason explained in the fetch stage below, since this environment has no outbound network access to arbitrary domains and no live key.
A list of competitor product URLs to track, and for each one, the specific text pattern its page uses to display a price (this article's parser looks for Price: $XX.XX; a real target site's actual markup will need its own pattern, checked against that page's real output).
Stage 1 — Fetch
A plain fetch() or requests.get() call only receives whatever HTML a server sends before any client-side JavaScript executes. Plenty of real storefronts render price and availability after that point, or return a different response entirely to a request that doesn't look like a browser. This is the stage where a hand-rolled scraper usually breaks first, and where a rendering-aware, proxy-backed fetch layer earns its place. The article uses Nstproxy Crawl'sPOST /api/v1/crawl/scrape endpoint (documented at docs.nstproxy.com/docs/crawl), which renders the page and returns clean Markdown instead of raw HTML:
Checking err and then success before trusting the payload matters here specifically: Nstproxy Crawl's documentation is explicit that an HTTP 200 only confirms the request was received, not that the target page was fetched successfully — a blocked or timed-out fetch still returns a response body that has to be checked, not just a status code.
This environment has no outbound network access to arbitrary domains and no live Nstproxy API key, so the verification run in this article substitutes a local HTTP fixture returning the exact nested response envelope Nstproxy Crawl's documentation specifies (outer code/err/msg/data, inner data.success/status, page payload at data.data.markdown) in place of the real endpoint — disclosed here rather than presented as a live call. The unwrapping logic exercised (err, then success, then data.data.markdown) is identical to what runs against the real endpoint; only the transport target changed for this test.
Stage 2 — Extract
The fetch stage returns Markdown, not a structured price field, so extraction is a matter of pulling a known pattern out of text. A real target page needs its own pattern checked against that page's actual rendered output; this article's fixture page renders Price: $129.99, so the extraction is a single regular expression:
import re
defparse_price(markdown_text):match= re.search(r"Price:\s*\$([0-9]+\.[0-9]{2})", markdown_text)ifnotmatch:returnNonereturnfloat(match.group(1))
Returning None on a failed match — rather than raising immediately — is deliberate: a page that temporarily fails to render a price shouldn't crash the whole pipeline run for every other competitor being tracked in the same batch. What happens to that None is decided in the store stage below, not here.
Stage 3 — Normalize
Every source eventually needs to look the same to the storage and comparison stages, regardless of which competitor or page format it came from:
Tracking scraped_at per record, not just per batch, is what makes the change-detection stage meaningful later — without a timestamp on each row, there's no way to tell which of two prices for the same URL is actually the more recent one.
Transform and store
Storing every check — including a failed extraction — is what separates a monitoring system from a scraper that happens to write to a database sometimes. A run that finds no price still writes a row with price_usd = NULL, so a gap in coverage is visible as an explicit null in the data rather than indistinguishable from "checked and unchanged":
The table itself is a single flat schema — no joins needed for a pipeline this size, using Python's standard-library sqlite3 module so no external database service is required to follow along:
definit_db(conn): conn.execute("""
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
competitor TEXT NOT NULL,
url TEXT NOT NULL,
price_usd REAL,
scraped_at TEXT NOT NULL
)
""") conn.commit()
Stage 5 — Detect price changes
Change detection is a comparison between the two most recent non-null prices stored for the same competitor and URL — no separate service or streaming pipeline is needed at this scale:
defdetect_change(conn, competitor_name, url): rows = conn.execute("""
SELECT price_usd, scraped_at FROM price_history
WHERE competitor = ? AND url = ? AND price_usd IS NOT NULL
ORDER BY id DESC LIMIT 2
""",(competitor_name, url),).fetchall()iflen(rows)<2:returnNone latest_price, latest_at = rows[0] previous_price, previous_at = rows[1]if latest_price == previous_price:returnNonereturn{"competitor": competitor_name,"url": url,"previous_price": previous_price,"new_price": latest_price,"delta":round(latest_price - previous_price,2),"previous_at": previous_at,"new_at": latest_at,}
Filtering out null prices in the WHERE clause means a single failed extraction doesn't get compared against the last real price and reported as a false "price change" — it's simply skipped until the next successful check.
Stage 6 — Alert
The alert stage is where a real system would call a notification API (Slack, email, a webhook into an internal tool); this article prints the same payload that call would send, so the logic is fully visible:
defalert(change): direction ="dropped"if change["delta"]<0else"rose" message =(f"[price-alert] {change['competitor']}{direction} from "f"${change['previous_price']:.2f} to ${change['new_price']:.2f} "f"({change['delta']:+.2f}) — {change['url']}")print(message)return message
Take a Quick Look
The fetch stage is where most price-monitoring pipelines break in production — Nstproxy Crawl handles JavaScript rendering and proxy-backed access behind one API call, instead of a scraper that quietly stops working the day a target site adds anti-bot protection.
Wiring the six stages together for one run is a single function that runs every tracked competitor through the same chain:
defrun_once(competitors, conn):for competitor in competitors: markdown = fetch_page(competitor["url"]) price = parse_price(markdown) record = normalize(competitor, price, datetime.now(timezone.utc).isoformat()) store(conn, record) change = detect_change(conn, competitor["name"], competitor["url"])if change: alert(change)else:print(f"[price-check] {competitor['name']}: no change detected")
This was executed twice against the fixture pages described in Stage 1 — the first run establishing a baseline price with nothing to compare against, the second simulating a real price drop a day later. Captured output, verbatim:
--- Run 1 ---
[price-check] Trailrunner Co.: $129.99 (no prior price to compare, or unchanged)
--- Run 2 (price changed) ---
[price-alert] Trailrunner Co. dropped from $129.99 to $114.99 (-15.00) — https://example-shop.test/products/trailrunner-3000
--- Stored history ---
('Trailrunner Co.', 129.99, '2026-08-24T07:18:20.727383+00:00')
('Trailrunner Co.', 114.99, '2026-08-24T07:18:20.730092+00:00')
The first run has no prior price to compare against, so detect_change correctly returns nothing. The second run's stored price differs from the first, so the alert fires with the exact delta (-15.00) computed directly from the two stored rows — not hardcoded or asserted, but read back from the same SQLite database the store stage wrote to.
To run this on a schedule rather than by hand, a standard crontab entry is enough for a pipeline this size — no orchestration framework is required to check a handful of competitors every few hours:
This pipeline collects publicly visible pricing information from competitor product pages — not account-gated content, personal data, or anything behind authentication — which is a meaningfully lower-risk category than scraping personal or private data. In hiQ v. LinkedIn, the Ninth Circuit held that scraping publicly accessible web data generally does not violate the U.S. Computer Fraud and Abuse Act, which is the closest thing to a settled baseline for "is scraping public pricing pages legal" in U.S. law — though that ruling addresses one specific statute, not every possible legal claim a site could raise (contract/terms-of-service claims among them). That doesn't make it risk-free. Check the target site's published terms of service before scraping it on a recurring schedule, keep request frequency reasonable rather than hammering a page far more often than a price actually changes, and don't use this pattern to collect anything beyond public pricing and availability (no attempts to access personalized pricing shown only to logged-in accounts, and no collection of customer reviews or personal data incidental to the page). A monitoring system built for legitimate competitive-intelligence purposes should stay scoped to exactly that.
Conclusion
An automated competitor price monitoring system is six verifiable stages, not one big scraper: fetch, extract, normalize, store, detect change, and alert. This article ran that full chain twice against a real (fixture-backed) fetch stage and a real SQLite database, and the price drop it reported came from an actual comparison of two stored rows, not a scripted example. The one stage worth taking seriously in production is the fetch — that's where JavaScript rendering and anti-bot protection actually break a naive implementation, which is why it's the one stage this article recommends offloading to a dedicated fetch layer rather than hand-rolling. For background on that layer, see the Nstproxy Crawl launch post, and check pricing against how many competitors and how often you plan to check them before committing a monitoring system's fetch stage to any hosted API.
FAQ
Q: How often should the pipeline check competitor prices?
It depends on how often the competitor actually changes prices and how time-sensitive that information is — a few times a day is enough for most retail categories, while flash-sale-prone categories may warrant hourly checks. Checking far more often than prices actually change wastes fetch calls without adding useful signal.
Q: What happens if a competitor changes their page layout and the price pattern stops matching?
The parse_price function returns None rather than raising, so the pipeline keeps running for every other tracked competitor; the affected row is stored with a null price rather than a stale or wrong one. A production system should alert on repeated null extractions for the same URL, since that's a signal the page's markup changed and the pattern needs updating — this article's single-pattern regex is a starting point, not a permanent solution for every target site.
Q: Does this work for sites with dynamic pricing or personalized offers shown only to logged-in users?
Not as built — this pipeline fetches public product pages, and pricing shown only to an authenticated account is out of scope per the "Responsible handling" section above. Personalized or account-gated pricing is a materially different (and more sensitive) category of data collection than a public listing price.
Q: How is this different from just checking prices manually?
Consistency and history. A person checking manually tends to check irregularly and rarely writes down every price they see, so there's no reliable history to compare against later; this pipeline stores every check — including unchanged and failed ones — so the change-detection stage always has a real prior value to compare a new price to.
Q: Can this track more than price — like stock status or shipping cost?
Yes — the pattern generalizes directly. Add another extraction pattern in Stage 2 for each additional field, another column in the price_history table, and extend detect_change to compare whichever fields matter; the fetch, store, and alert stages don't need to change at all.
Q: What's the cost of running this against dozens or hundreds of competitor products?
That scales with fetch volume more than anything else, since extraction, storage, and comparison are all local and effectively free at this scale. Check a fetch API's pricing against the number of competitors and check frequency planned before committing to a specific cadence, since fetch calls are usually the only line item that grows with scale.
Q: Is scraping competitor prices legal?
Scraping publicly visible pricing information is generally lower-risk than scraping personal or account-gated data, but "generally lower-risk" isn't the same as "risk-free everywhere" — check the target site's terms of service, keep request rates reasonable, and avoid collecting anything beyond the public price and availability data this pipeline is scoped to. This isn't legal advice; consult counsel for a specific target site or jurisdiction if there's genuine uncertainty.
Build a real open-source visual workflow builder for AI agents: a React Flow canvas paired with a verified topological-sort execution engine, tested end to end with real captured output.
Ivy Lin
Aug. 24th 2026
110M+ real IPs with 99.9% access success
Blazing-fast average response ~0.5s for high-concurrency tasks
From only $0.1/GB
Get immediate access to premium residential, datacenter, IPv6 and ISP proxy pools.