MegalaniaBlog
  1. Blog
  2. How to scrape
  3. Price tracking

How to track competitor prices and stock every day

A daily price and stock tracker in one Python file. Fetch each product page as JSON, save a snapshot, diff it with yesterday's. Real page costs inside.

Jordan HayesDeveloper Relations
8 min read
How to scrape
Seven day cards from Monday to Sunday, each with a price tag. On Friday the tag has moved, and a change mark and a bell sit over it.

The short answer: keep a list of product URLs. Once a day, fetch each page as JSON with a few extract rules (price, stock, SKU), save the result as a snapshot, and compare it with the last one. Print or send only what changed. On Shopify and WooCommerce stores, try the free JSON routes first. A product page costs about 3 credits on the datacenter route, from our price table (that route was off in our test), so 100 products a day is about 300 credits. On residential, a page was billed 24 credits.

The whole tracker is four steps:

  1. A URL list: one product page per line, in urls.txt.
  2. One request per URL: response_types: "json" with extract rules, so you get fields, not HTML.
  3. A snapshot and a diff: today’s fields saved to a file, compared with the last file.
  4. An alert: the changed lines, printed or sent where you read them.

The free routes first

Many stores give you this data for free. Try these before you pay anyone, us included:

cURL
curl -s "https://www.allbirds.com/products.json?limit=250&page=1"
curl -s "https://porterandyork.com/wp-json/wc/store/v1/products?per_page=1"

On 2 Oct 2026 these free routes answered on all five stores in this post. They are not enough when the store turned them off, blocks your script with a 403 or a bot check, answers 429 after a few calls, or when you need the price the shopper sees on the page. Then fetch the page itself.

Check a store first

Before you track a store, ask about the host. It is free. Over MCP, your agent calls check_site:

MCP check_site
{"name": "check_site", "arguments": {"host": "porterandyork.com"}}

From cURL, the same answer is two calls: /v1/tier for the price, /v1/stats/domain for how our routes have done there lately.

cURL
curl -s -H "Authorization: Bearer YOUR_KEY" \
  "https://get.megalania.io/v1/tier?host=porterandyork.com"
curl -s -H "Authorization: Bearer YOUR_KEY" \
  "https://get.megalania.io/v1/stats/domain?host=porterandyork.com&days=30"

This is what /v1/tier answered us:

JSON
{"host": "porterandyork.com", "tier": null, "temporary": null, "price": null, "pending": null,
 "quotes": {"residential": 15}, "capabilities": {"http": ["residential"], "browser": []}}

tier: null means we have not priced this host yet, so a page pays the price of the route that delivers it, and quotes shows that number. Our test key had the datacenter routes off, so it shows residential only. Yours also shows datacenter, which is cheaper. All five stores in this post answered tier: null, and /v1/stats/domain had no requests for them yet: we had no history there. Check a site first explains every field.

One product, one request (cURL)

The body is the exact one we ran, on a Shopify store. Send it with a heredoc, so the quotes in the rules need no shell escaping:

cURL
curl -sS https://get.megalania.io/v1 \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -D headers.txt \
  --data @- <<'JSON'
{
  "url": "https://www.allbirds.com/products/mens-dasher-nz",
  "response_types": "json",
  "extract": "title:meta[property=\"og:title\"]@content|price:meta[property=\"og:price:amount\"]@content|currency:meta[property=\"og:price:currency\"]@content|availability:script[type=\"application/ld+json\"]@json(offers.availability)|availability_variant:script[type=\"application/ld+json\"]@json(offers.0.availability)|sku:script[type=\"application/ld+json\"]@json(sku)",
  "max_credits": "64"
}
JSON

On WooCommerce stores we sent the same body with WooCommerce’s own class names:

text
name:h1.product_title|price:p.price ins .amount, p.price > .amount|regular_price:p.price del .amount|stock:p.stock|add_to_cart:form.cart button.single_add_to_cart_button|sku:.sku_wrapper .sku

Why these rules work on every theme is in the Shopify and WooCommerce posts. A rule that matches nothing comes back as null. max_credits is the most this page may cost; we skip any route above it.

Get a free beta key76,000 credits for 30 days. Sign in with Google or GitHub.Apply for the beta

Python: snapshot, diff, alert

One file. It reads urls.txt, picks the Shopify or WooCommerce rules by the URL path (/products/ or /product/), saves today’s fields to snapshots/, and prints every field that changed since the last snapshot.

track.py
# pip install requests
# Run once a day: MEG_API_KEY=YOUR_KEY python track.py urls.txt
import datetime
import json
import os
import pathlib
import sys
import uuid

import requests

LD = 'script[type="application/ld+json"]'
SHOPIFY = "|".join([
    'title:meta[property="og:title"]@content',
    'price:meta[property="og:price:amount"]@content',
    'currency:meta[property="og:price:currency"]@content',
    f"availability:{LD}@json(offers.availability)",
    f"availability_variant:{LD}@json(offers.0.availability)",
    f"sku:{LD}@json(sku)",
])
WOO = "|".join([
    "name:h1.product_title",
    "price:p.price ins .amount, p.price > .amount",
    "regular_price:p.price del .amount",
    "stock:p.stock",
    "add_to_cart:form.cart button.single_add_to_cart_button",
    "sku:.sku_wrapper .sku",
])
WATCH = ["price", "regular_price", "availability", "availability_variant", "stock", "add_to_cart", "sku"]


def fetch(url):
    rules = WOO if "/product/" in url else SHOPIFY
    resp = requests.post(
        "https://get.megalania.io/v1",
        headers={
            "Authorization": f"Bearer {os.environ['MEG_API_KEY']}",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={"url": url, "response_types": "json", "extract": rules, "max_credits": "64"},
        timeout=90,
    )
    if resp.status_code != 200:
        err = resp.json()["error"]
        print(f"{url}: {resp.status_code} {err['code']}: {err['message']}")
        return None
        return None
    return resp.json()


snapshots = pathlib.Path("snapshots")
snapshots.mkdir(exist_ok=True)
previous = sorted(snapshots.glob("*.json"))
old = json.loads(previous[-1].read_text()) if previous else {}

today = {}
for url in pathlib.Path(sys.argv[1]).read_text().split():
    item = fetch(url)
    if item is not None:
        today[url] = item

stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H%M")
(snapshots / f"{stamp}.json").write_text(json.dumps(today, indent=1))

compared = [url for url in today if url in old]
changes = [
    (url, key, old[url].get(key), today[url].get(key))
    for url in compared
    for key in WATCH
    if old[url].get(key) != today[url].get(key)
]
for url, key, before, after in changes:
    print(f"CHANGED {url} {key}: {before!r} -> {after!r}")
print(f"{len(today)} fetched, {len(compared)} compared, {len(changes)} changes")

A block is HTTP 422 with error.code blocked and X-Billing: blocked, and it costs 0 credits, so the status check catches it. X-Hops on that answer lists every route we tried. A page that fails today is left out of today’s snapshot, so it is not compared; it comes back the next day. The snapshot name has the minute, not the second: two runs in the same minute write the same file, and the second run overwrites the first. Once a day, that never happens.

Run it from cron once a day:

Shell
0 6 * * * cd /srv/tracker && MEG_API_KEY=YOUR_KEY python track.py urls.txt >> track.log

For the alert, send the CHANGED lines where your team reads them: a chat webhook, an email, or a row in your database.

What our two runs found

We fetched the same 20 product pages twice, about 28 hours apart: 3 Oct 2026 at 00:07 UTC and 4 Oct 2026 at 04:31 UTC. Then we compared the same seven fields the script watches.

Field Products in both runs Changed
price 20 0
regular_price 20 0
availability 20 0
availability_variant 20 0
stock 20 0
add_to_cart 20 0
sku 20 0

Nothing changed. That is a normal day for most products, and it is the honest result. It also tells you something useful: once a day is enough for most stores. A tracker that fetches every hour pays 24 times as much to learn the same thing. Fetch more often only when you know prices move, for example during a sale week.

regular_price is a WooCommerce field: it holds the old price during a sale. On a Shopify page the old (compare-at) price is not in the meta tags or the JSON-LD, so take it from /products.json if you need it.

What it costs per day

We fetch on the cheapest route first: plain HTTP on a datacenter exit (h:dc). If the store blocks it, we climb to a residential exit (h:res). The response headers say what happened:

http
x-credits-charged: 24
x-engine: http
x-hops: h:res:chrome=3653
x-identity: chrome
x-route: http/residential
x-target-status: 200

Measured on our dev cluster with the datacenter routes off, so this climb starts at h:res.

A page like this costs about 2-3 credits on h:dc (45-126 KiB, our five stores), worked out from our price table on 4 Oct 2026, because that route was off in our test; run check_site (free) to see the price for your site.

A page like this was billed 16-24 credits on h:res (45-126 KiB, our five stores) in our test on 3 and 4 Oct 2026; run check_site (free) to see the price for your site.

How the 3 adds up: 1 for the page, 1 for JSON output, and 1 for the started 128 KiB above the 64 KiB included. On h:res it is 15 on a host we have not priced yet, plus 1 for JSON, plus 8 for the extra block. One store had smaller pages (45-47 KiB), so its pages cost 2 on h:dc and 16 on h:res. Our second run of 20 pages was billed 448 credits in total, all on h:res.

Products tracked Per day, h:dc Per day, h:res 30 days, h:dc 30 days, h:res
100 300 2,400 9,000 72,000
1,000 3,000 24,000 90,000 720,000

The 76,000-credit beta grant covers 100 products a day for 30 days, even if every page needs a residential exit. The size is the bytes on the wire, which is what the price uses.

How we measured: 20 product pages, 4 each on www.allbirds.com, colourpop.com and www.deathwishcoffee.com (Shopify), and barefootbuttons.com and porterandyork.com (WooCommerce). Run 1 on 3 Oct 2026, 00:06 to 00:07 UTC; run 2 on 4 Oct 2026, 04:31 UTC. One request per page with the bodies above, not a batch.

More than a few hundred products

One request per product is fine for a few hundred pages. For more, send the same body as one batch with a urls list: Batches. Nothing is charged at submit; each URL is charged when we fetch it.

Being polite: rate and terms

  • Blocks we detect are free. The answer carries X-Billing: blocked and costs 0 credits. Each try shows in X-Hops.
  • Errors are free. Every error has a code and a fix in Error codes.
  • Keep your rate low. Once a day, a few seconds between pages on the same store. Our runs left 2.5 seconds between pages on one host.

This is not legal advice. Track public product pages only. Do not log in, and do not get past a password. Read each store’s terms and robots.txt, and follow them. Do not collect personal data, such as reviewer names.

Jordan HayesDeveloper RelationsPublished 18 Oct 2026 · measured on our dev cluster, 3 and 4 Oct 2026, 20 product pages on 5 stores, twice

Keep reading