Amazon's Best Sellers lists are a public signal, updated through the day, of what is selling in each category. One snapshot tells you what is popular. A series of snapshots tells you what is changing: which products are climbing, which just entered the top 100, which dropped out, and whose price moved before they did.
Amazon doesn't keep that history for you. This post shows how to build it: take a daily snapshot, store it as a file, and diff any two snapshots in plain Python.
Disclosure: I'm Aurelix, and I built the Amazon Best Sellers Actor used for the data step. It's a paid tool (prices in the Cost section). The snapshot-and-diff part works with data from any source.
The general approach
-
Pick lists, not searches. Each category and subcategory has its own Best Sellers list of up to 100 ranks (for example
amazon.com/gp/bestsellers/kitchen). - Key every row by ASIN. The ASIN is Amazon's product ID and stays stable while titles get edited.
- Snapshot on a fixed schedule. Lists change through the day, so compare like with like: the same time each day.
- Store raw snapshots, compute diffs later. Disk is cheap; you'll want to ask questions you didn't think of on day one.
- Diff by ASIN: present in both (rank moved?), only in the new one (new entry), only in the old one (dropped out).
Getting the data
The Amazon Best Sellers Actor takes:
-
categories(required): a slug from the Best Sellers URL ("kitchen","pet-supplies","beauty") or a full list URL, including subcategory URLs. -
listType:"bestsellers"or"new-releases"(for bare slugs). -
maxPages:1= top 50,2= top 100, which is Amazon's maximum. -
onlyNewEntriesandstateKey: covered below.
A record looks like this:
{
"list_type": "bestsellers",
"category_name": "Kitchen & Dining",
"category_url": "https://www.amazon.com/gp/bestsellers/kitchen",
"rank": 1,
"asin": "B0BZYCJK89",
"title": "Owala FreeSip Stainless Steel Water Bottle 24 oz Denim",
"product_url": "https://www.amazon.com/dp/B0BZYCJK89",
"image_url": "https://images-na.ssl-images-amazon.com/images/I/61Gvw0-b0eL._AC_UL300_SR300,200_.jpg",
"price": 29.99,
"price_max": 29.99,
"price_text": "$29.99",
"currency": "USD",
"rating": 4.6,
"reviews_count": 144563,
"page": 1,
"previous_rank": 1,
"rank_change": 0,
"new_entry": false,
"source_url": "https://www.amazon.com/gp/bestsellers/kitchen",
"scraped_at": "2026-11-04T11:24:07Z"
}
The Actor already compares each run with your previous run of the same list and fills previous_rank, rank_change (previous minus current, so positive means it climbed) and new_entry. That covers "what moved since yesterday". For "what moved since last Monday", or across any two dates you pick, you want your own snapshots. That's what the rest of this post builds.
Step 1: Save a daily snapshot
pip install apify-client
import json
import os
from datetime import date
from pathlib import Path
from apify_client import ApifyClient
CATEGORIES = ["kitchen", "pet-supplies"]
SNAPSHOT_DIR = Path("snapshots")
def slug_of(item):
return item["category_url"].rstrip("/").split("/gp/")[-1].replace("/", "_")
def take_snapshot():
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("aurelix/amazon-best-sellers").call(
run_input={
"categories": CATEGORIES,
"listType": "bestsellers",
"maxPages": 2,
"stateKey": "daily-snapshot",
}
)
by_list = {}
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
by_list.setdefault(slug_of(item), []).append(item)
today = date.today().isoformat()
for slug, items in by_list.items():
folder = SNAPSHOT_DIR / slug
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{today}.json"
path.write_text(json.dumps(items, indent=1), encoding="utf-8")
print(f"{path}: {len(items)} products")
if __name__ == "__main__":
take_snapshot()
Run it daily with cron (0 9 * * * python3 snapshot.py) or an Apify schedule. You end up with one snapshots/bestsellers_kitchen/<date>.json file per day. The stateKey gives this job its own "previous run" memory, so testing the Actor by hand doesn't disturb the built-in rank_change values of the scheduled runs.
Step 2: Diff two snapshots
import json
import sys
from pathlib import Path
def load(path):
items = json.loads(Path(path).read_text(encoding="utf-8"))
return {item["asin"]: item for item in items}
def diff(old_path, new_path, min_move=10):
old, new = load(old_path), load(new_path)
entered = [new[a] for a in new.keys() - old.keys()]
dropped = [old[a] for a in old.keys() - new.keys()]
moved = []
repriced = []
for asin in new.keys() & old.keys():
before, after = old[asin], new[asin]
delta = before["rank"] - after["rank"]
if abs(delta) >= min_move:
moved.append((delta, after))
if before.get("price") is not None and after.get("price") is not None:
if before["price"] != after["price"]:
repriced.append((before["price"], after["price"], after))
print(f"New in top list ({len(entered)}):")
for item in sorted(entered, key=lambda i: i["rank"]):
print(f" #{item['rank']:>3} {item['asin']} {item['title'][:70]}")
print(f"\nDropped out ({len(dropped)}):")
for item in sorted(dropped, key=lambda i: i["rank"]):
print(f" was #{item['rank']:>3} {item['asin']} {item['title'][:70]}")
print(f"\nMoved {min_move}+ places ({len(moved)}):")
for delta, item in sorted(moved, key=lambda m: -m[0]):
arrow = "up" if delta > 0 else "down"
print(f" {arrow:4} {abs(delta):>3} -> #{item['rank']:>3} {item['title'][:60]}")
print(f"\nPrice changed ({len(repriced)}):")
for was, now, item in sorted(repriced, key=lambda r: r[1] - r[0]):
print(f" {was:>8.2f} -> {now:>8.2f} #{item['rank']:>3} {item['title'][:55]}")
if __name__ == "__main__":
diff(sys.argv[1], sys.argv[2])
Usage:
python3 diff.py snapshots/bestsellers_kitchen/<earlier>.json snapshots/bestsellers_kitchen/<later>.json
A few details in there are deliberate:
-
Rank delta is
old - new, the same sign convention as the Actor'srank_change: positive means the product climbed. -
A
min_movethreshold. Ranks near the bottom of a list jitter by a few places every day. A threshold of 10 cuts the noise; lower it for the top 20, where small moves matter. - Prices are compared only when both snapshots have one. Some products show no price on the list (for example when they're sold only through other offers), and treating a missing price as zero would invent price drops.
Step 3: Build a rank history
Once you have a few weeks of files, turning them into one long table is a few lines:
import csv
import json
from pathlib import Path
rows = []
for path in sorted(Path("snapshots/bestsellers_kitchen").glob("*.json")):
day = path.stem
for item in json.loads(path.read_text(encoding="utf-8")):
rows.append({
"date": day,
"asin": item["asin"],
"rank": item["rank"],
"price": item.get("price"),
"reviews_count": item.get("reviews_count"),
"title": item["title"],
})
with open("kitchen_history.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["date", "asin", "rank", "price", "reviews_count", "title"])
writer.writeheader()
writer.writerows(rows)
Load that into a spreadsheet or pandas and pivot on asin × date. Two useful views:
- Days in the top 100 per ASIN: sustained demand rather than a one-day spike.
-
reviews_countgrowth next to rank: a product climbing while gaining reviews quickly is selling; one climbing with flat reviews may be riding a promotion.
Only want the newcomers?
If all you need is "what joined the list", skip the snapshots and set onlyNewEntries: true. The run then returns only products that weren't on the same list at your previous run (the first run returns everything). Rank changes are filled in either way.
Limits
- Amazon.com (US) only in this version. Other Amazon sites' URLs are rejected with a clear message.
-
Movers & Shakers isn't supported: Amazon serves those pages empty to automated visitors. Daily Best Sellers snapshots plus
rank_changecover the same question. - Up to 100 ranks per list, Amazon's own maximum. Amazon sometimes repeats a product at two ranks while the list updates; the repeat is dropped, so a list can return 98 or 99 products. Your diff will see that as a "drop-out" for a day, which is another reason for a threshold.
-
Some products show no price on the list;
priceis then empty, never guessed. - A run is a snapshot of the moment it ran. Lists change through the day.
- If Amazon starts answering with robot checks mid-run, the products already read are kept and the run status says "Partial". Check the product count before trusting a diff against that day.
Why this tool over the alternatives
Compared with the leading alternatives on the Apify Store (the full table is on each Store page):
- $1.80 per 1,000 products, 69% under the most-used Best Sellers scraper ($5.90), and $0.38 on paid plans, the lowest of the three.
- 10 marketplaces including amazon.ca, plus rank change and new entries since your last run. Not offered: Movers & Shakers and Most Wished For.
Cost
At the time of writing, the README lists $1.80 per 1,000 products on Apify's free plan and $0.38 per 1,000 on paid plans (Bronze, Silver, Gold), plus $0.001 per run. Unknown categories and unsupported pages return nothing and cost nothing beyond the start fee.
Two top-100 lists a day is about 200 products, or $0.36 a day on the free plan. Apify's free plan includes $5 of credit a month, which the README puts at about 2,700 products: roughly 27 days of one top-100 list.
Links
- Amazon Best Sellers Scraper on the Apify Store
- Finding a slug: open a category on amazon.com/gp/bestsellers; the slug is the part after
/bestsellers/. For a subcategory, paste the whole URL.
Top comments (0)