If you sell online, a surprising number of your competitors run on Shopify, and almost every Shopify store exposes its whole catalogue as public JSON. In this post:
- The
/products.jsontrick, and a 15-line DIY scraper. - Monitoring several stores on a daily schedule and getting only price drops, new sale prices, new products and restocks, as a CSV log you can chart.
Every Shopify store has a products feed
Append /products.json to most Shopify storefronts and you get the catalogue: titles, vendors, tags, every variant with SKU, price, compare-at (original) price and availability. It pages 250 products at a time:
import requests
def shopify_products(store):
page = 1
while True:
r = requests.get(f"{store}/products.json", params={"limit": 250, "page": page}, timeout=30)
products = r.json().get("products", [])
if not products:
break
yield from products
page += 1
for p in shopify_products("https://www.tentree.com"):
v = p["variants"][0]
print(p["title"], v["price"], v.get("compare_at_price"), v["available"])
That's enough for one small store, once. For ongoing monitoring you start needing more:
- Many stores, in parallel, without getting rate-limited (bigger stores throttle quickly from a single IP).
- Collection-only scraping (
/collections/womens/products.json). - A clean row per variant (size/colour) with SKU and barcode.
- And the important bit: diffing against yesterday so you only look at what changed.
Let a scheduled Actor do the diffing
I built Shopify Store Products & Price Tracker on Apify for exactly this. Its onlyChanges mode remembers the previous run for each store and outputs only rows that are new or whose price, sale price or stock changed, tagging each with change: "new" or change: "updated". You only pay for those rows.
pip install apify-client
export APIFY_TOKEN=... # Apify Console > Settings > API & Integrations
One-off: the full catalogue, one row per variant
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("olsols4/shopify-store-products").call(run_input={
"stores": [
"https://www.tentree.com",
"https://www.tentree.com/collections/womens", # or just one collection
],
"outputPerVariant": True, # one row per size/colour, with SKU and barcode
"onlyAvailable": False,
"includeDescription": False,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
print(row["title"], "|", row["variantTitle"], "|", row["price"], row["compareAtPrice"], row["available"])
A real per-variant row from a test run today (trimmed):
{
"store": "https://www.tentree.com",
"title": "Kessler Sweater",
"vendor": "tentree",
"productType": "Womens",
"variantTitle": "WARM OAK HEATHER FADE / XS",
"sku": "TCW6319-5805-XS",
"price": 58.8,
"compareAtPrice": 98,
"available": true,
"url": "https://www.tentree.com/products/kessler-sweater-warm-oak-heather-fade"
}
Prices are in the store's default currency. Product mode (outputPerVariant: false) gives one row per product with priceMin, priceMax, onSale, available and the variants nested.
Daily: only the changes, appended to a CSV
Set the input up once in Apify Console with onlyChanges: true, save it as a task, and add a Schedule (say 07:00 daily). The first run returns everything as new (your baseline); later runs return just the diffs. Then pull the latest run from a script, whenever you like:
# price_log.py
import csv, os, datetime
from pathlib import Path
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
# Most recent successful run of the Actor in your account (e.g. from the daily schedule)
run = client.actor("olsols4/shopify-store-products").last_run(status="SUCCEEDED")
rows = run.dataset().list_items().items
log = Path("price_changes.csv")
is_new_file = not log.exists()
today = datetime.date.today().isoformat()
with log.open("a", newline="") as f:
w = csv.writer(f)
if is_new_file:
w.writerow(["date", "store", "change", "title", "variant", "sku", "price", "compare_at", "available", "url"])
for r in rows:
w.writerow([today, r["store"], r.get("change"), r["title"], r.get("variantTitle"),
r.get("sku"), r.get("price"), r.get("compareAtPrice"), r.get("available"), r["url"]])
on_sale = [r for r in rows if r.get("compareAtPrice") and r.get("price") and float(r["compareAtPrice"]) > float(r["price"])]
print(f"{len(rows)} changed rows today, {len(on_sale)} currently discounted")
for r in on_sale[:20]:
pct = 100 * (1 - float(r["price"]) / float(r["compareAtPrice"]))
print(f' -{pct:.0f}% {r["title"]} ({r.get("variantTitle")}) now {r["price"]}')
(This assumes the scheduled task uses outputPerVariant: true; in product mode use priceMin/compareAtPriceMax instead.)
After a couple of weeks price_changes.csv is a tidy history: load it into pandas or a spreadsheet and you can see who discounts when, how deep, and which sizes keep selling out.
If you'd rather not run a script at all, attach Apify's Google Sheets, Slack or webhook integration to the task so each day's changes land there automatically.
What people use it for
- Competitor price monitoring: a daily diff of 5–20 competitor stores.
-
Restock alerts: filter for
change == "updated"andavailable == Trueon items you care about (combine withkeywords). - Product research / dropshipping: see full catalogues, price ranges and what's in stock across a niche.
- Catalogue matching: per-variant rows with SKU and barcode.
Cost and honest limits
- $0.80 per 1,000 rows. In change mode you only pay for changed rows, so a daily diff of ten stores is typically cents per day. Stores that aren't on Shopify, or that block catalogue access, return nothing and cost nothing.
- Some big brands disable the public catalogue (in my testing today, a couple of well-known DTC stores returned nothing). This works for the many stores that keep it on, which is most of them.
- Stock is in-stock / sold-out per variant; Shopify doesn't publish exact quantities.
- Use it for public catalogue data and respect each store's terms.
If a store you need fails, open an issue on the Actor's page with the URL.
Top comments (0)