Build a price tracker with an AI agent: Python, datawebot MCP and SQLite
Build a Python price tracker without writing a scraper: find products across US shops through an MCP server, keep dated rows in SQLite and report changes.
Most price scraping tutorials in Python start with one retailer's product page: fetch the HTML, find the price with a CSS selector, save it. That works until the page layout changes, and it covers one shop. This tutorial takes a different route. A short Python script asks the datawebot MCP server to find each product across US online shops, keeps only the listings that match, stores one dated row per seller offer in SQLite, and prints what changed since the last run.
There are no selectors and no HTML parsing. Everything below was run on 18 Oct 2026, and the output shown is the real output.
What will you build?
A script, price_tracker.py, about 110 lines, standard library only, that:
- reads a watchlist of products with matching rules,
- calls
search_productsto find each product in US shops, - picks the first result that passes the rules,
- calls
get_product_offersfor every seller's price, total with shipping and link, - drops offers that fail the rules,
- writes one row per kept offer to
prices.db, - compares each offer with the previous run and prints the difference.
You schedule it with cron or GitHub Actions. That part matters: datawebot returns a best-effort snapshot each time you call it. It does not run checks on a schedule, send alerts or keep price history. The script and its SQLite file are the tracker.
How do you set up access?
Tell your coding agent (Claude Code, Cursor or similar):
set up https://mcp.datawebot.com/setup.md
The agent follows the setup page, asks for your email, and you approve the sign-in from the email datawebot sends. There is no key to copy by hand. The key is saved to ~/.datawebot/credentials, readable only by you, and the script below reads it from there. On a server or in CI, put the key in an environment variable named DATAWEBOT_API_KEY instead, from your secret store, and never commit it. Search and offers are free to try within a daily allowance.
If you want to see what the server offers before writing code, ask your agent to call discover. On 18 Oct 2026 it listed search_products (arguments: query, country, optional sites and limit) and get_product_offers (arguments: product_id, plus the same query and country as the search). Those are the two tools this script uses.
What does the code look like?
Python 3.9 or later, no packages to install. We ran it with Python 3.14.
"""Track a small product basket with the datawebot MCP server and keep a dated
history in SQLite. Run it on a schedule (cron, GitHub Actions); each run adds
one row per seller offer and prints what changed since the previous run."""
import json, os, re, sqlite3, urllib.request
from datetime import datetime, timezone
MCP_URL = "https://mcp.datawebot.com/mcp"
DB_PATH = os.environ.get("TRACKER_DB", "prices.db")
# What to track. "must" words have to appear in the listing title, "reject"
# words must not, and anything below min_price is treated as a different item
# (an accessory, a rental fee or a bait listing).
WATCHLIST = [
{"item": "switch2", "query": "Nintendo Switch 2 console", "must": ["switch 2"],
"reject": ["refurbished", "pre-owned", "used", "rent", "bundle", "+", "accessories", "mario", "pokemon"],
"min_price": 400},
{"item": "dyson-v15", "query": "Dyson V15 Detect", "must": ["v15 detect"],
"reject": ["refurbished", "absolute", "extra", "total clean", "submarine", " pro", "v15s"],
"min_price": 300},
{"item": "ninja-af101", "query": "Ninja AF101 air fryer", "must": ["af101"],
"reject": ["refurbished", "pre-owned", "used", "basket only", "replacement"],
"min_price": 50},
]
def api_key():
key = os.environ.get("DATAWEBOT_API_KEY")
if key:
return key
# Written by the agent setup (https://mcp.datawebot.com/setup.md).
with open(os.path.expanduser("~/.datawebot/credentials")) as f:
return json.load(f)["profiles"]["https://mcp.datawebot.com"]["api_key"]
def call_tool(name, arguments):
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": arguments}}
req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), headers={
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": "Bearer " + api_key()})
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
if raw.startswith("event:") or "\ndata:" in raw: # streamed reply
raw = "\n".join(l[5:].strip() for l in raw.splitlines() if l.startswith("data:"))
result = json.loads(raw)["result"]
payload = result.get("structuredContent") or json.loads(result["content"][0]["text"])
if result.get("isError"):
raise RuntimeError(f"{name} failed: {json.dumps(payload)[:300]}")
return payload
def matches(title, spec):
t = " " + title.lower() + " "
return all(w in t for w in spec["must"]) and not any(w in t for w in spec["reject"])
def pick_product(spec):
found = call_tool("search_products", {"query": spec["query"], "country": "US", "limit": 10})
for r in found["data"]["results"]:
if (r.get("product_id") and r.get("has_offers") and r.get("condition") in (None, "new")
and r.get("price") and r["price"] >= spec["min_price"] and matches(r["title"], spec)):
return r
return None
def setup(db):
db.execute("""CREATE TABLE IF NOT EXISTS observations (
checked_at TEXT, as_of TEXT, item TEXT, product_id TEXT, shop TEXT,
title TEXT, price REAL, total REAL, currency TEXT, url TEXT)""")
def total_of(offer):
m = re.search(r"[\d,]+\.?\d*", offer.get("total_text") or "")
return float(m.group().replace(",", "")) if m else offer.get("price")
def run():
db = sqlite3.connect(DB_PATH)
setup(db)
checked_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
for spec in WATCHLIST:
try:
product = pick_product(spec)
if not product:
print(f"{spec['item']}: no clean match in this search; nothing stored")
continue
res = call_tool("get_product_offers", {"product_id": product["product_id"],
"query": spec["query"], "country": "US"})
except Exception as e: # a failed call is a gap in the history, not a zero
print(f"{spec['item']}: {e}")
continue
as_of = res["meta"]["as_of"]
kept = [o for o in res["data"]["offers"]
if o.get("price") and o["price"] >= spec["min_price"] and matches(o["title"], spec)]
dropped = len(res["data"]["offers"]) - len(kept)
prev = dict(db.execute("""SELECT url, total FROM observations WHERE item = ? AND checked_at =
(SELECT MAX(checked_at) FROM observations WHERE item = ? AND checked_at < ?)""",
(spec["item"], spec["item"], checked_at)).fetchall())
for o in kept:
total, url = total_of(o), o["url"].split("?")[0] # drop tracking parameters
db.execute("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?)",
(checked_at, as_of, spec["item"], product["product_id"], o["shop"],
o["title"], o["price"], total, o["currency"], url))
old = prev.get(url)
note = "new listing" if old is None else ("unchanged" if old == total else f"was {old:.2f}")
print(f"{spec['item']:12} {o['shop'][:26]:26} {total:8.2f} {o['currency']} {note}")
print(f"{spec['item']}: kept {len(kept)} offers, dropped {dropped}, as_of {as_of}")
db.commit()
if __name__ == "__main__":
run()
A few design choices worth knowing:
- Matching rules live in the watchlist.
mustwords have to be in the title,rejectwords must not be, andmin_priceremoves accessories, rental payments and bait listings. The rules run twice: on search results and again on each seller offer. - Totals, not list prices.
total_ofreads the total including shipping where the offer has one. - One row per offer per run. The table never updates old rows, so it is a true history.
- Failures are not prices. If a call fails, the script prints the error and stores nothing for that item.
- Links are the identity. Each offer is compared with the previous run by its link, with tracking parameters removed, because one shop name such as "eBay" can hold several sellers.
What did the first run return?
First run, 18 Oct 2026, started 07:22:11 UTC:
$ python3 price_tracker.py
switch2 GameStop 499.99 USD new listing
switch2 Best Buy 499.99 USD new listing
switch2 Target 499.99 USD new listing
switch2 Nintendo 499.99 USD new listing
switch2 BJ's Wholesale Club 499.99 USD new listing
switch2 staples.com 499.99 USD new listing
switch2 Abt Electronics & Applianc 514.84 USD new listing
switch2 Walmart - Domino Deals 639.99 USD new listing
switch2 Macy's 499.99 USD new listing
switch2 Lowe's 609.99 USD new listing
switch2 Dell 499.99 USD new listing
switch2 PC Richard & Son 499.99 USD new listing
switch2: kept 12 offers, dropped 1, as_of 2026-10-18T07:22:13.246Z
dyson-v15 Williams-Sonoma 849.95 USD new listing
dyson-v15 Chewy.com 849.99 USD new listing
dyson-v15 The Container Store 849.99 USD new listing
dyson-v15 Wayfair 849.99 USD new listing
dyson-v15 Bed Bath & Beyond 789.49 USD new listing
dyson-v15 Dillard's 849.99 USD new listing
dyson-v15 Perigold 850.00 USD new listing
dyson-v15 BrandsMart USA 849.99 USD new listing
dyson-v15 Belk 849.99 USD new listing
dyson-v15 eBay - superdealer5 549.99 USD new listing
dyson-v15 eBay 529.00 USD new listing
dyson-v15 eBay 629.00 USD new listing
dyson-v15: kept 12 offers, dropped 1, as_of 2026-10-18T07:22:13.702Z
ninja-af101: no clean match in this search; nothing stored
Three things in this output are worth reading closely.
One item found nothing, and that is correct. To see why, we ran the "Ninja AF101 air fryer" search again on its own a few seconds later (07:22:21 UTC). None of the ten results had "AF101" in the title. They included a Ninja Crispi microwave air fryer at $399.99, an SP101 air fry oven, pre-owned items on Mercari and eBay, and generic "Ninja Air Fryer" and "Ninja 4 Qt Air Fryer" listings from $33.28 to $94.00. The script refused to guess and stored nothing. A tracker that picked the closest title would have recorded the price of a different product.
The filter dropped one real console. For the Switch 2, the dropped offer was BrandsMart USA, whose listing title was "2 Gaming Console". It lacks "switch 2", so the rule removed it, even though the link shows the console. You can loosen the rule or add a per-shop exception; either way, it is a choice you make in code, not something the data decides.
The filter dropped one wrong variant. For the Dyson, the dropped offer was ShopSimon's "V15 Detect Total Clean Extra" at $349.99 plus $4.95 delivery, a different kit grouped under the same product. Without the extra rule the tracker would have reported the V15 Detect as about $495 cheaper at one shop than at the others.
Two kept rows also need a person's judgment. "Walmart - Domino Deals" at $639.99 is a seller on Walmart's site, not necessarily Walmart itself. The three eBay offers for the Dyson, $529.00 to $629.00, do not state condition in the offer, and two said "Final sale". Treat them as leads to check, not as competitor prices, until you have looked at the listing.
What does a second run look like?
Second run, 18 Oct 2026, started 07:25:08 UTC, three minutes later:
$ python3 price_tracker.py
switch2 GameStop 499.99 USD unchanged
switch2 Best Buy 499.99 USD unchanged
switch2 Target 499.99 USD unchanged
switch2 Nintendo 499.99 USD unchanged
switch2 BJ's Wholesale Club 499.99 USD unchanged
switch2 staples.com 499.99 USD unchanged
switch2 Abt Electronics & Applianc 514.84 USD unchanged
switch2 Walmart - Domino Deals 639.99 USD unchanged
switch2 Macy's 499.99 USD unchanged
switch2 Lowe's 609.99 USD unchanged
switch2 Dell 499.99 USD unchanged
switch2 PC Richard & Son 499.99 USD unchanged
switch2: kept 12 offers, dropped 1, as_of 2026-10-18T07:25:09.976Z
dyson-v15 Williams-Sonoma 849.95 USD unchanged
dyson-v15 Chewy.com 849.99 USD unchanged
dyson-v15 The Container Store 849.99 USD unchanged
dyson-v15 Wayfair 849.99 USD unchanged
dyson-v15 Bed Bath & Beyond 789.49 USD unchanged
dyson-v15 Dillard's 849.99 USD unchanged
dyson-v15 Perigold 850.00 USD unchanged
dyson-v15 BrandsMart USA 849.99 USD unchanged
dyson-v15 Belk 849.99 USD unchanged
dyson-v15 eBay - superdealer5 549.99 USD unchanged
dyson-v15 eBay 529.00 USD unchanged
dyson-v15 eBay 629.00 USD unchanged
dyson-v15: kept 12 offers, dropped 1, as_of 2026-10-18T07:25:11.742Z
ninja-af101: no clean match in this search; nothing stored
Nothing changed in three minutes, which is what you would expect. On a real schedule, a change prints as was 499.99 next to the new total, and a seller that appears for the first time prints as new listing.
How do you query the history?
The database now holds two checks. Lowest and highest matched total per item per check:
SELECT item, checked_at, COUNT(*) AS offers, MIN(total) AS lowest, MAX(total) AS highest
FROM observations GROUP BY item, checked_at ORDER BY item, checked_at;
item checked_at offers lowest highest
--------- ------------------------- ------ ------ -------
dyson-v15 2026-10-18T07:22:11+00:00 12 529.0 850.0
dyson-v15 2026-10-18T07:25:08+00:00 12 529.0 850.0
switch2 2026-10-18T07:22:11+00:00 12 499.99 639.99
switch2 2026-10-18T07:25:08+00:00 12 499.99 639.99
Every price change across all runs, using an SQLite window function:
SELECT item, shop, prev_total, total, checked_at
FROM (
SELECT item, shop, url, total, checked_at,
LAG(total) OVER (PARTITION BY item, url ORDER BY checked_at) AS prev_total
FROM observations
)
WHERE prev_total IS NOT NULL AND prev_total <> total
ORDER BY checked_at DESC;
For these two runs it returned no rows, because no price changed. Once the script runs daily for a few weeks, this is the query that feeds a weekly review. For more on table design, indexes and retention, see the price history database recipe.
How do you run it on a schedule?
With cron on any machine that stays on. Run daily at 13:00 UTC:
0 13 * * * cd /path/to/tracker && /usr/bin/python3 price_tracker.py >> tracker.log 2>&1
With GitHub Actions, using the schedule event. Store the key as a repository secret named DATAWEBOT_API_KEY, and commit the database back so the next run sees the history. Use a private repository, because the database holds your basket.
name: price-tracker
on:
schedule:
- cron: "0 13 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
track:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python price_tracker.py
env:
DATAWEBOT_API_KEY: ${{ secrets.DATAWEBOT_API_KEY }}
- run: |
git config user.name "price-tracker"
git config user.email "[email protected]"
git add prices.db
git commit -m "Price check $(date -u +%F)" || echo "No changes"
git push
GitHub notes that scheduled workflows can start late at busy times, so do not rely on an exact minute. For a basket checked once a day, that does not matter.
Can the agent do the review for you?
Yes. Once the history exists, the same agent that set up datawebot can read it. Ask: "Open prices.db, run the price change query for the last 7 days, and list any item where a Best Buy, Target or Walmart offer moved by more than 5%." The agent runs the SQL, then calls get_product_offers again for anything that looks odd, and returns the links for you to check. Our agentic workflows recipe covers where to keep a person in the loop.
How do Best Buy, Walmart and Target price trackers work?
Most consumer price trackers, such as camelcamelcamel and Keepa for Amazon, or the browser extensions that cover Best Buy, Walmart and Target, work the same way underneath: they check a product on a schedule, store each price with a date, draw a chart from the stored rows and send an alert when a price crosses a threshold. The history and the alerts come from the storage and the schedule, not from the retailer.
The script above is the same machine for your own basket. To narrow it to those three retailers, filter on the shop name. Our 18 Oct 2026 history for the Switch 2:
SELECT shop, total, checked_at FROM observations
WHERE item = 'switch2' AND (shop IN ('Best Buy', 'Target') OR shop LIKE 'Walmart%')
ORDER BY checked_at, shop;
shop total checked_at
---------------------- ------ -------------------------
Best Buy 499.99 2026-10-18T07:22:11+00:00
Target 499.99 2026-10-18T07:22:11+00:00
Walmart - Domino Deals 639.99 2026-10-18T07:22:11+00:00
Best Buy 499.99 2026-10-18T07:25:08+00:00
Target 499.99 2026-10-18T07:25:08+00:00
Walmart - Domino Deals 639.99 2026-10-18T07:25:08+00:00
The Walmart row is a seller on Walmart's site (the name after the dash), so if you only want Walmart's own offer, match shop = 'Walmart' exactly. In these two checks, no offer labelled only "Walmart" came back for this product, although the search results showed one at $591.90. That is normal: seller lists vary between calls and between products.
To add a price-drop alert, compare total with your threshold after each insert and send the message from your own code, to email, Slack or wherever your team reads. datawebot does not send notifications.
What are the limits?
- Snapshots, not current prices. Every price above was retrieved on 18 Oct 2026 between 07:20 and 07:25 UTC, best-effort and unverified. Do not quote them as current.
- Matching is rule-based. The rules here caught the wrong kit and the missing model, and also dropped one valid console. Review dropped and kept offers for each new item before you trust its history.
- Seller lists change. A seller missing from one run may be back in the next. Do not read a missing row as a price change or a stock-out.
- Online US prices only. In-store, ZIP-code and membership prices are not covered.
- Some sources are limited. Amazon results are an invite-only preview on datawebot, so this tutorial does not track Amazon. Shopify store extraction is temporarily unavailable.
- Daily allowance. Each run makes two calls per item. Size the basket and schedule to stay within the free allowance, which each response reports as searches left today.
For the process around a tracker like this, see competitor price tracking across US retailers. For what to look for in any price data source, see price tracking APIs: what they return and what they don't. Matching is covered in depth in product matching, and filling missing catalog fields in product data enrichment.
Frequently asked questions
How do I do price scraping in Python without writing a scraper?
Call a product data service from Python instead of parsing pages. In this tutorial, Python sends search_products and get_product_offers calls to the datawebot MCP server with the standard library, filters the results with title and price rules, and stores them in SQLite.
Does datawebot store my price history?
No. Each call returns a dated, best-effort snapshot. The history in this tutorial lives in your own prices.db file, and the schedule comes from cron or GitHub Actions.
Can I track Amazon prices with this script?
Not today. Amazon results are an invite-only preview on datawebot, so the tutorial uses products sold at other US retailers such as Best Buy, Target and Walmart sellers.
How many products can I track?
Each product costs one search and one offers call per run. Searches and offers are free to try within a daily allowance, and every response reports how many searches are left today, so start with a small basket and grow it once you see your usage.
What should the script do when a search returns no clean match?
Store nothing and say so, as the Ninja AF101 example did. Then improve the query (add the model number, brand or "console"), adjust the rules, or remove the item. Never record the closest title's price as if it were the product.
Sources
- https://mcp.datawebot.com/setup.md
- https://www.datawebot.com/llms.txt
- https://docs.python.org/3/library/sqlite3.html
- https://www.sqlite.org/windowfunctions.html
- https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
- https://modelcontextprotocol.io/specification/2025-06-18/server/tools
- https://www.datawebot.com/learn/build-product-price-history-database
- https://www.datawebot.com/learn/agentic-workflows-inventory-pricing
Put product data in your agent's hands.
Connect a coding agent, or check how page-based credits work before you run anything.