DEV Community

Caesar Rodman
Caesar Rodman

Posted on

Track one product's price on Amazon, Walmart and eBay with Python (and the traps in each)

Checking a price on one store is easy. Checking the same product on Amazon, Walmart and eBay every day, and getting the price you'd actually pay, is where it gets fiddly: every store has its own way of showing you a number that isn't the one you want.

This post builds a small tracker in Python for one real product (Sony's WH-1000XM5 headphones, black). It:

  1. reads the lowest new price on Amazon, Walmart and eBay,
  2. stores every reading in SQLite,
  3. tells you when a store hits a new low or drops under your target.

Along the way it deals with three traps I hit while building it: Amazon's buy box showing a used offer, eBay's cheapest results being spare parts, and shipping that changes depending on where the request comes from.

Full disclosure: I'm building API.SH, which is what fetches the store data here. Every account gets 1,000 free credits a month, enough to run this tracker daily for several products. Every code block below was run before publishing, and the output under it is what it printed.

Setup

pip install apish-sdk
export APISH_API_KEY="ash_live_..."
Enter fullscreen mode Exit fullscreen mode

The SDK needs Python 3.9 or newer. Get a key from the dashboard after signing up.

Trap 1: Amazon's price isn't always a new item

The obvious move is to read the product page and take its price:

from apish import Apish

apish = Apish()  # reads APISH_API_KEY

p = apish.amazon.product(url="B09XS7JWHH")
print(p["title"][:45], "|", p["price"], "|", p["seller"])

offers = apish.amazon.offers(asin="B09XS7JWHH", condition="new")
for o in offers["offers"][:3]:
    print(o["buy_box"], o["condition"], o["price_value"], o["sold_by"])
Enter fullscreen mode Exit fullscreen mode
Sony WH-1000XM5 Premium Noise Cancelling Wire | $176.40 | Adorama
True Used - Like New 176.4 Adorama
False New 263.95 Video & Audio Center - Same Day Shipping
False New 263.95 Video & Audio Center - Same Day Shipping
Enter fullscreen mode Exit fullscreen mode

$176.40 looks like a great price, but it's the buy box, and today the buy box is a used, like new offer. The cheapest new one is $263.95. If you track the product page price, your chart jumps between new and used prices whenever Amazon moves the buy box.

The offers endpoint lists every seller's offer, the same as Amazon's "Other sellers" panel, with a condition on each one. Note that the buy box offer is pinned to the top even when you ask for condition="new", so filter on the condition field yourself as well. It's also a lighter call than the full product page, which matters when you run it every day.

Trap 2: eBay's cheapest results are spare parts

res = apish.ebay.search(q="sony wh-1000xm5", condition="new", buying="buy_it_now", sort="price_asc")
for item in res["items"][:4]:
    print(item["price_value"], item["title"][:60])
Enter fullscreen mode Exit fullscreen mode
42.99 Sony Headphones WH-1000XM5 XM5 Headband Hinge Swivel Replace
44.99 For SONY WH-1000XM5 Wireless Noise Canceling Bluetooth Headp
8.98 Lot Sony Headphones WH-1000XM5 XM5 Headband Wire Board Conne
6.99 Sony WH-1000XM5 Headphones 16-Pin Wire Clip Connector OEM fo
Enter fullscreen mode Exit fullscreen mode

Sort by price on eBay and you get hinges, earpads and connectors, all "new" and all matching the model name. Two fixes: a price floor (min_price) well below any real deal but above the accessories, and eBay's own excluded-words filter (exclude) for the parts vocabulary.

You'll also notice the results aren't in strict price order, and that price_value is the item price alone: shipping comes as text in shipping ("Free delivery", "+$14.99 delivery"). The tracker parses it and adds it, so it compares the price you'd actually pay.

Trap 3: shipping depends on where you are

eBay quotes shipping to the visitor's location, so the same listing can show free shipping on one request and a few dollars on the next. When I ran the same search twice, the cheapest listing showed "+$5.00 delivery" and then "+$3.00 delivery". The eBay search endpoint takes a zip parameter, which pins the shipping quotes to one ship-to ZIP code. Use your own, so the landed price is what you'd pay.

The three price functions

prices.py:

import re
from apish import Apish

apish = Apish()  # reads APISH_API_KEY


def money(text):
    """'+$14.99 delivery' -> 14.99, 'Free delivery' -> 0.0"""
    m = re.search(r"\$([\d,]+\.?\d*)", text or "")
    return float(m.group(1).replace(",", "")) if m else 0.0


def amazon_price(asin):
    res = apish.amazon.offers(asin=asin, condition="new")
    new = [o for o in res["offers"] if o["condition"] == "New" and o["price_value"]]
    best = min(new, key=lambda o: o["price_value"])
    return best["price_value"], best["sold_by"], res.meta.credits_used


def walmart_price(item_id):
    res = apish.walmart.product(url=item_id)
    new = [o for o in res["offers"] if o["is_new"] and o["price"]]
    best = min(new, key=lambda o: o["price"])
    seller = (res.get("seller_info") or {}).get("name")
    return best["price"], seller, res.meta.credits_used


def ebay_price(query, exclude, min_price, zip_code="10001"):
    res = apish.call("/ebay/v1/search", {
        "q": query, "condition": "new", "buying": "buy_it_now", "location": "us",
        "sort": "price_asc", "min_price": min_price, "exclude": exclude,
        "zip": zip_code,  # shipping on each card is quoted to this ZIP
    })
    landed = [(i["price_value"] + money(i["shipping"]), i) for i in res["items"] if i["price_value"]]
    total, item = min(landed, key=lambda x: x[0])
    return round(total, 2), item["url"], res.meta.credits_used
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • Walmart has the same new-versus-not problem as Amazon: the Walmart product endpoint returns its offers by condition (new, open box and so on), each with is_new. You can pass the item id from the product URL (walmart.com/ip/.../386006068) as url.
  • eBay uses apish.call() with the route, which passes anything the endpoint accepts. I wrote this against an SDK release whose typed apish.ebay.search() didn't have exclude and zip yet; from apish-sdk 0.3.0 it does, so either works.
  • Each function returns credits_used from the response, so you can see what a run costs.

A first check:

from prices import amazon_price, walmart_price, ebay_price

print("Amazon ", amazon_price("B09XS7JWHH"))
print("Walmart", walmart_price("386006068"))
print("eBay   ", ebay_price("sony wh-1000xm5 headphones",
                            exclude="hinge repair earpads replacement parts case wf-1000xm5",
                            min_price=120))
Enter fullscreen mode Exit fullscreen mode
Amazon  (263.95, 'Video & Audio Center - Same Day Shipping', 1)
Walmart (198, 'Focus Camera', 1)
eBay    (149.99, 'https://www.ebay.com/itm/377441133552', 2)
Enter fullscreen mode Exit fullscreen mode

eBay has no product ID shared across sellers the way Amazon has an ASIN, so the eBay number really means "the cheapest new listing that matches these words". Today's winner is titled WH-1000XM5SA, not the black model the other two rows track. That's why the function returns the listing URL: you check it before you buy.

Finding the same product on another store

You might hope to match products automatically by barcode. Walmart gives you the UPC, and Amazon's search accepts a UPC as the query:

from apish import Apish

apish = Apish()

wm = apish.walmart.product(url="386006068")
print(wm["name"], "| UPC", wm["upc"], "| model", wm["model"])

hits = apish.amazon.search(q=wm["upc"])
for p in hits["products"][:3]:
    print(" ", p["asin"], p["sponsored"], p["title"][:70])
Enter fullscreen mode Exit fullscreen mode
Sony WH-1000XM5 The Best Wireless Noise Canceling Headphones, Black | UPC 027242923232 | model WH1000XM5/B
  B0CFSD4TSQ False Sony WH-1000XM5 Noise Canceling Wireless Headphones - 30hr Battery Lif
  B0F7SQMJHP False Sony WH-1000XM5 Wireless Industry Leading Noise Canceling Headphones w
  B0F3PT1VBL True Sony WH-1000XM6 The Best Noise Cancelling Wireless Headphones Midnight
Enter fullscreen mode Exit fullscreen mode

None of those is the listing we're tracking (B09XS7JWHH). The first is an "International Version, No Warranty" listing, the third is the next model, and it's a sponsored ad. Barcode search is a good way to find candidates, but for a tracker you'll want to pick the IDs once by hand and keep them in a watchlist.

The tracker

track.py keeps a watchlist, stores every reading, and alerts on two things: a new lowest price for that store, and the first time a store goes under your target.

import sqlite3
from datetime import datetime, timezone

from prices import amazon_price, walmart_price, ebay_price

WATCHLIST = [
    {
        "name": "Sony WH-1000XM5 (black)",
        "target": 250.00,
        "amazon": "B09XS7JWHH",
        "walmart": "386006068",
        "ebay": {
            "query": "sony wh-1000xm5 headphones",
            "exclude": "hinge repair earpads replacement parts case wf-1000xm5",
            "min_price": 120,
        },
    },
]

db = sqlite3.connect("prices.db")
db.execute("""create table if not exists prices (
    checked_at text, product text, store text, price real, detail text)""")


def check(product):
    readings = {}
    for store, fetch, arg in [
        ("amazon", amazon_price, product["amazon"]),
        ("walmart", walmart_price, product["walmart"]),
        ("ebay", lambda q: ebay_price(**q), product["ebay"]),
    ]:
        try:
            price, seller, credits = fetch(arg)
        except Exception as e:  # one store failing shouldn't stop the others
            print(f"  {store:8} failed: {e}")
            continue
        readings[store] = (price, seller)
        print(f"  {store:8} ${price:>8.2f}  {seller}  ({credits} cr)")
    return readings


def previous_low(product, store):
    row = db.execute("select min(price) from prices where product = ? and store = ?",
                     (product, store)).fetchone()
    return row[0]


now = datetime.now(timezone.utc).isoformat(timespec="seconds")
for p in WATCHLIST:
    print(p["name"])
    readings = check(p)
    alerts = []
    for store, (price, seller) in readings.items():
        low = previous_low(p["name"], store)
        if low is not None and price < low:
            alerts.append(f"{store} is at a new low: ${price:.2f} (was ${low:.2f})")
        if price <= p["target"] and (low is None or low > p["target"]):
            alerts.append(f"{store} is under your target: ${price:.2f} <= ${p['target']:.2f}")
        db.execute("insert into prices values (?, ?, ?, ?, ?)", (now, p["name"], store, price, seller))
    db.commit()
    if readings:
        store, (price, _) = min(readings.items(), key=lambda kv: kv[1][0])
        print(f"  cheapest: {store} at ${price:.2f}")
    for a in alerts:
        print("  ALERT:", a)
Enter fullscreen mode Exit fullscreen mode

First run, with an empty database:

Sony WH-1000XM5 (black)
  amazon   $  263.95  Video & Audio Center - Same Day Shipping  (1 cr)
  walmart  $  198.00  Focus Camera  (1 cr)
  ebay     $  149.99  https://www.ebay.com/itm/377441133552  (2 cr)
  cheapest: ebay at $149.99
  ALERT: walmart is under your target: $198.00 <= $250.00
  ALERT: ebay is under your target: $149.99 <= $250.00
Enter fullscreen mode Exit fullscreen mode

Second run, prices unchanged, so no alerts:

Sony WH-1000XM5 (black)
  amazon   $  263.95  Video & Audio Center - Same Day Shipping  (1 cr)
  walmart  $  198.00  Focus Camera  (1 cr)
  ebay     $  149.99  https://www.ebay.com/itm/377441133552  (2 cr)
  cheapest: ebay at $149.99
Enter fullscreen mode Exit fullscreen mode

The target alert fires only when a store crosses the target (its previous low was above it), so you aren't told the same thing every morning. The new-low alert fires whenever a store beats its own record.

To send the alerts somewhere useful, replace the print(" ALERT:", a) line with a Slack or Discord webhook, an email, or a push service.

Run it every day

A cron entry is all it takes:

0 8 * * * cd ~/price-tracker && APISH_API_KEY=ash_live_... python3 track.py >> track.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Two things to know when you schedule it:

  • Caching. Responses are cached for 24 hours, and a repeat call with the same parameters returns the stored copy (it costs the same as a live call). Once a day that never matters. If you check several times a day, pass options={"fresh": True} to each call so you get a live price.
  • What it costs. At the credits shown in the output above, one product is 4 credits per run, so 1,000 free credits a month cover about 8 products checked daily. Calls that fail on our side aren't charged. Current prices are on the pricing page.

Where to take it next

  • Price history charts: select checked_at, store, price from prices straight into pandas or a spreadsheet.
  • More stores: the same pattern works for Target, Best Buy, Costco and others in the catalog; each needs its own "what is the real new price" rule.
  • No code: there's a ready-made n8n template that tracks competitor prices and reviews on Amazon, Walmart and eBay, logs them to Google Sheets and posts changes to Slack.

The store pages for each API are at api.sh/amazon-api, api.sh/walmart-api and api.sh/ebay-api.

What's the worst price trap you've hit on a store? I'd like to collect them.

Top comments (0)