TL;DR: You do not need to keep a product page open in a tab and refresh it like it owes you money. There are three ways to get told the moment a price falls. First, use the trackers and saved searches that already exist for big marketplaces. Second, poll any product page with a short Python script that reads the price the store itself embeds in the page, remembers it, and speaks up only when the number goes down. Third, run a scheduled monitor that does the remembering for you. Delta Tools built one for marketplace listings. It is called the OfferUp Fresh Listing + Price Change Monitor, and it watches OfferUp searches and reports each price drop with the old price and the size of the drop included. All three methods are below, with working code for the second one.
I learned this lesson on a camera lens. The listing sat at $850 for weeks. On a Tuesday morning it fell to $640. By Friday it was back at $810, and it stayed there for another month. I saw none of it, because I checked the page on Monday and again on the weekend, and in my head the price simply was $850. The drop was real. My timing was the problem.
That pattern repeats everywhere once you start looking. Sellers cut prices quietly. Stores run short sales that never get announced. A marketplace seller who has had no messages in ten days trims $40 off a listing rather than writing a better description. None of these events send you a notification by default. The page just changes, silently, and waits for whoever happens to be watching.
Here are three ways to be the person who is watching, from simplest to fully automated.
Before we start: the Delta Tools shortcut for the marketplace version of this problem is our OfferUp Fresh Listing and Price Change Monitor, which watches your OfferUp searches on a schedule and reports only what changed, including price drops, price increases, new listings, and listings that disappeared. It covers OfferUp rather than every store on the internet, and I will be plain about that again later. The rest of this article teaches you to watch any product page by hand, which is the skill that transfers everywhere.
Method 1: Use the trackers and saved searches that already exist (free, minutes)
For the biggest marketplaces, somebody has already built the watcher and gives it away. Your job in this method is simply to find the right one and turn it on properly.
Amazon is the most served store on earth for this. Keepa tracks the price history of Amazon products and can alert you when a price crosses a threshold you set. CamelCamelCamel does the same job with price history charts and drop alerts, and it has done it for years. Both are free for basic use. Both also share the same limit, which is that they only know Amazon. The moment your product lives anywhere else, they know nothing about it.
Marketplaces reward a different habit. On eBay, run your search with the filters you actually care about, save the search, and turn on the email alerts for it. On OfferUp, save the search in the app and allow notifications. New listings then come to you, and when you open the listing you can see the current asking price. What these saved searches do poorly is history. They tell you a listing exists. They rarely tell you that the same listing was $550 last week and is $480 today, because the marketplace shows you the present and quietly forgets the past. Remembering the past is the entire trick of this article, and it is why Method 2 exists.
General page watching services sit between those two worlds. Tools such as Visualping and Distill watch a page or a selected part of a page and notify you when the content changes. They work on almost any store, they need no code, and their free tiers are enough for a handful of pages checked at gentle intervals. Their weakness is that they watch everything on the page, not the price specifically. A rotated banner, a new review count, or a seasonal badge can all trigger a change alert that has nothing to do with money. You will read a few false alarms before you learn to select just the price element, and on some stores the price element moves around too much for that to stay reliable.
When this is enough: you watch a small number of products, they live on Amazon or on a marketplace you already use, and a history chart plus a threshold alert covers what you need.
When it is not: the product lives on a store nobody tracks, you want alerts for a drop of a specific size rather than any change at all, or you watch enough pages that sorting real drops from page noise becomes its own chore. Keep reading.
Method 2: Poll any product page with a script you control (free, works everywhere)
A product page knows its own price, and most stores state that price in several places at once, in formats meant for machines. That fact is what makes a small script reliable enough to trust.
Where the price actually lives on a page
The first place to look is the structured data block. Many stores embed a script block of type application/ld+json in the page head. Inside it, a product is described with fields search engines use, and an offers section usually carries a price field and a priceCurrency field with values such as USD or EUR. This is the cleanest source on the page because the store wrote it for Google, and stores keep it accurate for the same reason you are reading it. Wrong structured data costs them clicks in search results.
The second place is the Open Graph tags in the page head. A meta tag with property product:price:amount carries the price, and a companion tag carries the currency. Stores add these so shared links render nicely in chats and social feeds, and they tend to be current because a stale price in a shared link preview is embarrassing in public.
The third place is microdata in the visible markup. An element with itemprop price, again from the schema.org vocabulary, either holds the price in a content attribute or prints it as text a shopper can read.
The last resort is the visible text itself. The first dollar amount on a page is often the price. It is also often a trap, because pages print installment amounts, accessory prices, and struck through list prices near the real one. The script below tries the three machine readable sources first and only falls back to visible text when nothing else answers. When you adopt it, check the baseline it records for each page once, by eye, so you know which rung of the ladder each of your pages landed on.
The script
The script keeps a watch list of names and page addresses. For each page it fetches the HTML, extracts the price with the ladder above, and compares the result with the price stored in a small JSON state file. A lower price produces an alert line, printed and optionally posted to a webhook so it lands in Slack or Discord or anywhere else that accepts a JSON post. An equal or higher price produces silence, and the new price becomes the reference for the next check.
import json
import os
import re
import urllib.request
from datetime import datetime, timezone
STATE_FILE = "price_state.json"
WEBHOOK_URL = os.environ.get("PRICE_WEBHOOK_URL", "")
MIN_DROP_PERCENT = 0.0
WATCHLIST = {
"espresso machine": "https://example.com/products/espresso",
"trail shoes": "https://example.com/products/trailshoes",
}
def fetch_html(url):
req = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 price watch script"},
)
with urllib.request.urlopen(req, timeout=45) as resp:
return resp.read().decode("utf-8", errors="replace")
def to_float(raw):
try:
return float(str(raw).replace(",", ""))
except Exception:
return None
def price_from_node(node):
if isinstance(node, dict):
offers = node.get("offers")
if isinstance(offers, dict):
raw = offers.get("price")
if raw is None:
raw = offers.get("lowPrice")
if raw is not None:
return to_float(raw)
if isinstance(offers, list):
for offer in offers:
found = price_from_node({"offers": offer})
if found is not None:
return found
for value in node.values():
found = price_from_node(value)
if found is not None:
return found
if isinstance(node, list):
for item in node:
found = price_from_node(item)
if found is not None:
return found
return None
def find_price_in_jsonld(html):
blocks = re.findall(
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>',
html,
flags=re.S,
)
for block in blocks:
try:
data = json.loads(block.strip())
except Exception:
continue
found = price_from_node(data)
if found is not None:
return found
return None
def find_price_in_meta(html):
match = re.search(
r'property="product:price:amount"\s+content="([\d][\d,]*\.?[\d]*)"',
html,
)
if match:
return to_float(match.group(1))
return None
def find_price_in_microdata(html):
match = re.search(
r'itemprop="price"[^>]*content="([\d][\d,]*\.?[\d]*)"',
html,
)
if not match:
match = re.search(
r'itemprop="price"[^>]*>([\d][\d,]*\.?[\d]*)<',
html,
)
if match:
return to_float(match.group(1))
return None
def find_price_in_text(html):
match = re.search(r"\$\s*([\d][\d,]*\.?[\d]*)", html)
if match:
return to_float(match.group(1))
return None
def extract_price(html):
finders = (
find_price_in_jsonld,
find_price_in_meta,
find_price_in_microdata,
find_price_in_text,
)
for finder in finders:
price = finder(html)
if price is not None:
return price
return None
def find_title(html):
match = re.search(r"<title>(.*?)</title>", html, flags=re.S)
if match:
return match.group(1).strip()
return ""
def announce(line):
print(line)
if WEBHOOK_URL:
payload = json.dumps({"text": line, "content": line}).encode()
req = urllib.request.Request(
WEBHOOK_URL,
data=payload,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=30).read()
def main():
first_run = not os.path.exists(STATE_FILE)
state = {}
if not first_run:
state = json.load(open(STATE_FILE))
checked = 0
for name, url in WATCHLIST.items():
try:
html = fetch_html(url)
except Exception as exc:
print(f"SKIP {name}: fetch failed ({exc})")
continue
price = extract_price(html)
if price is None:
print(f"SKIP {name}: no price found on page")
continue
checked += 1
old = state.get(url)
if old is not None and not first_run:
old_price = float(old["price"])
if price < old_price:
drop = old_price - price
percent = drop / old_price * 100
if percent >= MIN_DROP_PERCENT:
line = (
f"PRICE DROP: {name} was ${old_price:,.2f} "
f"and is now ${price:,.2f} (down {percent:.1f} percent)"
)
announce(line + "\n " + url)
state[url] = {
"name": name,
"price": price,
"title": find_title(html),
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
json.dump(state, open(STATE_FILE, "w"), indent=2)
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
if first_run:
print(f"{stamp} Baseline built: {checked} prices recorded, none announced.")
print("Run it again later and only genuine drops will print.")
else:
print(f"{stamp} Checked {checked} pages.")
if __name__ == "__main__":
main()
Three details in that script deserve a callout, because each one bit me before I learned to handle it on purpose.
The first run builds a baseline in silence. If the script announced every price on its first run, you would learn what things cost today, which you already know, and nothing about what changed. So the first run records and stays quiet. From the second run onward, it speaks only when a number moves down. If you edit the watch list later, expect the new pages to join quietly in the same way.
The comparison is against the last price seen, so set a floor for what counts. A seller who trims a price in small steps will trigger an alert at every step, and five alerts about a slow slide from $500 to $460 will teach you to ignore the channel. Set MIN_DROP_PERCENT to 3 or 5 and the script only speaks when a drop is worth your attention. Leave it at zero while you are testing, so you can watch it work on the first small change you can arrange.
The state file is the entire memory, and it is fragile in specific ways. It lives on the machine that runs the script. If that machine is asleep at the scheduled hour, that hour simply never happens. If the file is deleted, the baseline resets and the script goes quiet for one run while it relearns the world. If a page starts failing and returns no price, the script skips it and keeps the old reference, which is the behavior you want, because a store hiccup should never read as a drop to zero. None of this is hard to live with. All of it is yours to live with, on your hardware, forever.
Schedule it with cron on Linux or macOS, or with Task Scheduler on Windows. Hourly checks are a sane default for ordinary shopping. A line like the one below runs the script at the top of every hour and appends its output to a log you can review:
0 * * * * /usr/bin/python3 /home/you/price_watch.py >> /home/you/price_watch.log 2>&1
Be a polite guest while you do this. One request per page per check is a trivial load, and hourly checks on a handful of pages will never trouble a store. Check a page every thirty seconds and you will meet the two status codes that end polite conversations. A 403 means the server understood your request and refuses to answer it. A 429 means you are asking too often, and the correct response is to slow down, not to get clever. Stores also write their rules into their terms, and a personal watcher that fetches a few public pages an hour sits in a very different place from a scraper harvesting a whole catalog. Keep it in the first place.
Set the PRICE_WEBHOOK_URL environment variable to a Slack or Discord webhook and the printed lines become push alerts in a channel you already read. Leave it unset and the log file is your alert feed, which is honestly enough for anything that is not racing other buyers.
If you want the middle ground between building this and forgetting it exists, this is where I should mention the Delta Tools option again, because it exists for the exact moment the script starts feeling like a second job: the OfferUp Fresh Listing and Price Change Monitor runs this same pattern on a schedule for OfferUp searches, with the remembering handled for you.
Method 3: A scheduled monitor for marketplace listings (no infrastructure)
Method 3 is Method 2 with the fragile parts removed, aimed at the place where prices move the most arbitrarily, which is other people and their listings. A store reprices with software and a plan. A person reprices with feelings and a quiet Tuesday. The same couch sits at $300 for a month, then falls to $220 because the seller is moving on Friday. That is the drop worth catching, and it is the drop a saved search alone will never show you, because the marketplace presents the listing as if $220 was always the price.
The monitor is a small Apify actor. You give it searches, and each search names a keyword, a ZIP code, and a radius, with options such as a maximum price and how many listings to scan. On every scheduled run it fetches the current listings, compares them against a snapshot it keeps between runs, and writes only the changes to its output dataset. Each change row carries a type, and the type matters. A price drop row includes the new price, the old price, and the drop as a percent, so you can judge in one glance whether $480 down from $550 deserves your evening. The same run also reports price increases, new listings, edited listings, and listings that disappeared, and a disappearance usually means the item sold, which is information too. Removal is only reported after a listing misses two runs in a row, so a listing that briefly falls out of the results window does not cry wolf.
Give each scheduled monitor its own stateKey if you run more than one, and their memories stay separate. One key for the camera gear near your home, another for the furniture search you run for a friend, no cross talk between them. No OfferUp login is needed, and the actor makes its requests against the public search interface at light volume.
The pricing matches the philosophy of the script. You pay $0.001 when a run starts and $0.005 for each change it reports. A scheduled run that finds nothing new costs the start fee and nothing more. Compare that with scrapers that charge for every row on every run, including the listings you saw yesterday at the same prices. On this design, a quiet week costs you almost nothing, and a busy week costs you pennies in exchange for being early.
Now the honest scope note, because it decides whether this method is yours. The monitor watches OfferUp. It does not watch a retailer product page, and I will not dress it up as if it does. If the thing you want is a specific product on a specific store site, Method 2 is your tool and it is a good one. If the thing you want is whatever the used market near you is repricing this week, the OfferUp Fresh Listing and Price Change Monitor is the version where someone else keeps the lights on, and you just read the feed!
The traps that make price alerts lie
Every method above can be fooled in the same handful of ways. Knowing them is the difference between an alert feed you trust and one you mute by the end of the month.
The price on the page is not always the price you pay. Variants are the classic case. One page sells twelve configurations, the structured data reports the cheapest one, and the configuration you want never moves. If a drop alert sends you to a page and the number looks wrong, check which variant the page opened on before you blame the watcher.
Location changes prices. Stores price by region, by warehouse stock, and sometimes by the shipping address they have guessed from your visit. A script checking from a server in another state can see a different number from the one your browser shows you at home. When a drop matters, confirm it the way you would buy it, signed in and with your own delivery area set.
Some prices only exist in the cart. Coupons applied at checkout, member prices revealed after sign in, and rebates claimed by mail all live below the layer any page watcher can see. Page watching tracks the public price honestly. It cannot track the private arithmetic of a loyalty program, and any tool that promises otherwise is guessing.
A missing price is not a price of zero. When an item sells out, many stores remove the price from the page or replace the offers block entirely. A naive watcher reads that silence as a total collapse and sends the happiest false alert of your week. The script above treats no price found as a skip and keeps the old reference. Whatever you build or adopt, insist on the same behavior.
Pages fight back, politely at first. A store that does not want automated visits will usually start with slow responses, then a 403, then a challenge page that contains no price at all. Your watcher will report skips, which is the signal to slow down or stop for that store, not to escalate. The public web stays public partly because guests behave like guests.
FAQ
How often should I check a product page for a price drop?
Hourly is plenty for almost everything a person buys. Stores and sellers rarely reprice more often than that in ways that matter, and hourly checks keep your request volume trivial. Daily checks are fine for slow categories such as furniture. Save the aggressive intervals for marketplace listings racing other buyers, and even then, think in hours rather than minutes.
What size of drop should trigger an alert?
Start with any drop at all while you learn what normal movement looks like on your pages. After a week or two you will know which of your products jitter by a dollar and which sit frozen for months. Then set a floor, in percent or in plain dollars, that matches what would actually make you buy. An alert you would not act on is noise wearing a costume.
Can I watch pages in other currencies with the script?
Yes, with one small change. The structured data and meta tag rungs of the ladder read plain numbers and ignore the currency entirely, so they already work for euros, pounds, and yen. Only the visible text fallback looks for a dollar sign. Swap that symbol for the one your store prints and the fallback works there too, and the state file will happily store the numbers either way.
Why does my watcher see a different price from my browser?
Usually location, as covered above, and sometimes a signed in member price or a variant the page selected for you. Fetch the page the way a stranger would and you get the stranger price. Neither number is fake. They are answers to different questions, and your watcher answers the public one.
What does the marketplace monitor cost on a quiet week?
Almost nothing, by design. Each run costs $0.001 to start, and each reported change costs $0.005. A daily schedule on a week where nothing changes costs under a cent in start fees. You pay in proportion to how much genuinely changes, which is how monitoring ought to be priced.
Do I need an account or a login to start with the script?
No. The script in Method 2 uses only the Python standard library, runs on any machine that can reach the pages you watch, and stores its memory in a local file you can open and read. The marketplace monitor needs an Apify account to run on a schedule, and no OfferUp login at any point.
Top comments (0)