DEV Community

Aurelix Solutions
Aurelix Solutions

Posted on

Track Amazon best-seller rank changes over time with Python

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

  1. 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).
  2. Key every row by ASIN. The ASIN is Amazon's product ID and stays stable while titles get edited.
  3. Snapshot on a fixed schedule. Lists change through the day, so compare like with like: the same time each day.
  4. Store raw snapshots, compute diffs later. Disk is cheap; you'll want to ask questions you didn't think of on day one.
  5. 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.
  • onlyNewEntries and stateKey: 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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

Usage:

python3 diff.py snapshots/bestsellers_kitchen/<earlier>.json snapshots/bestsellers_kitchen/<later>.json
Enter fullscreen mode Exit fullscreen mode

A few details in there are deliberate:

  • Rank delta is old - new, the same sign convention as the Actor's rank_change: positive means the product climbed.
  • A min_move threshold. 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)
Enter fullscreen mode Exit fullscreen mode

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_count growth 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_change cover 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; price is 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

Top comments (0)