🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

How to Scrape Best Buy Product Data: SKUs & Stock

Scrape Best Buy product data at scale: pull SKU, price, stock, and ratings from the page's JSON-LD, get past Akamai, and pin store pickup availability.

S SparkProxy 0 24 min read
Share
How to Scrape Best Buy Product Data: SKUs & Stock

To scrape Best Buy product data cleanly, you have two jobs that pull in opposite directions: read the structured data Best Buy already ships inside every page, and get past Akamai Bot Manager, the anti-bot layer that decides whether you load that page at all. The parsing half is friendlier than most retailers, because every Best Buy product page carries a JSON-LD Product block with the SKU, price, availability, and aggregate rating already typed for you. The access half is the fight. Best Buy runs Akamai aggressively and, on high-demand launches, drops shoppers into a virtual waiting room. This guide walks the whole pipeline for public catalog data: the fields worth pulling, how to read them out of JSON-LD, how to catch an Akamai block that hides behind a normal-looking response, how store-level pickup availability works, where the ratings and reviews actually come from, and how to discover SKUs at volume. Every code sample uses SparkProxy's Scraping API, so the anti-bot work is one request parameter instead of an infrastructure project.

What Best Buy data you can extract (fields reference)

A public Best Buy product page lives at /site//.p?skuId=, where is the numeric SKU that uniquely names the product. That SKU is your primary key. It appears in the URL, in the page's JSON-LD, and in Best Buy's own API, which makes it the one identifier that ties every data source together. Here is the reference set worth pulling, along with where each field lives in the JSON-LD Product object.

FieldJSON-LD keyExampleNotes
SKU`sku``"6565837"`Numeric primary key; also the last path segment of the URL
Title`name``"Sony - WH-1000XM5 Wireless Headphones"`Full product name
Brand`brand.name``"Sony"`Brand string, nested under a `Brand` object
Model`mpn` or `model``"WH1000XM5/B"`Manufacturer part number; not always present
Price`offers.price``399.99`Numeric current price, national online price
Currency`offers.priceCurrency``"USD"`Currency code
Availability`offers.availability``"https://schema.org/InStock"`schema.org enum: `InStock`, `OutOfStock`, `PreOrder`
Rating`aggregateRating.ratingValue``4.7`Average rating, 0 to 5
Review count`aggregateRating.reviewCount``2841`Integer count of reviews
Image`image``"https://pisces.bbystatic.com/..."`Primary product image URL
GTIN`gtin13` / `gtin``"0027242920813"`Barcode; useful for cross-retailer matching

One point that catches people out: for Best Buy, the online price in offers.price is a single national number. Best Buy does not run the store-by-store base-price variance that grocery-heavy retailers do. The store-level dimension on Best Buy is availability, not price. Open-box and clearance items are the exception, since those are priced per unit and per condition, so treat an open-box listing as its own SKU-plus-condition record rather than a variant of the new item.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The JSON-LD advantage

Best Buy's storefront is a React application, so scraping the rendered DOM with CSS selectors is the brittle path: class names are hashed and the layout shifts. Skip it. Every product page embeds a JSON-LD script tag that describes the product in schema.org's vocabulary:

<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","sku":"6565837",
 "name":"Sony - WH-1000XM5 Wireless Headphones",
 "offers":{"@type":"Offer","price":399.99,"priceCurrency":"USD",
           "availability":"https://schema.org/InStock"},
 "aggregateRating":{"@type":"AggregateRating","ratingValue":4.7,"reviewCount":2841}}
</script>

That one detail changes the whole job. Instead of chasing selectors, you grab the JSON, parse it once, and read typed fields straight off the object. Price is a number, reviewCount is an integer, availability is a schema.org enum you can normalize to a boolean. No regex on "$399.99", no guessing which of five DOM containers holds the live price this session.

Two practical catches. First, a Best Buy page usually ships more than one JSON-LD block. You will often see a BreadcrumbList and sometimes an Organization block alongside the Product, so you cannot grab the first script tag and trust it. Filter by @type == "Product". Second, the JSON-LD carries the aggregate rating but not the individual reviews; those load from a separate widget, which we cover in the reviews section. For SKU, price, availability, and the rating summary, JSON-LD is the cleanest and most stable source on the page.

Why Best Buy is hard: Akamai and the queue

Getting the HTML is the hard part, and there are two reasons, one constant and one occasional.

The constant is Akamai Bot Manager. Best Buy sits behind Akamai's edge, and Akamai does not just check IP reputation. It fingerprints the TLS/JA3 handshake, inspects header order, and validates a _abck cookie that is set only after client-side sensor JavaScript runs and posts telemetry back. A plain requests.get with default headers fails that sensor check before the IP even matters, which is why bare HTTP clients get walled almost immediately. When Akamai decides you are a bot, the usual response is an "Access Denied" page carrying an Akamai reference number, often on a 403 but sometimes wrapped so the status looks benign. You have to inspect the body, not just the status code. The mechanics of _abck, bm_sz, and the sensor payload are covered in depth in how to bypass Akamai Bot Manager.

The occasional reason is the virtual waiting room. On high-demand launches (GPU restocks, new consoles, popular preorders), Best Buy puts shoppers into a queue: a "please wait" or "you're in line" holding page that throttles traffic to the product. For everyday catalog and price scraping you rarely see it, but if you target a product on launch day you will, and a scraper that treats the queue page as a product page stores garbage. Detect it and back off rather than hammering through.

SignalWhat you'll seeHow to handle it
Akamai block"Access Denied" body with a "Reference #" and Akamai edge error, often on 403Detect in body, rotate residential IP, retry with stealth
Sensor failureImmediate challenge on a raw HTTP fetch, no `_abck` setRender with a real browser so the sensor runs
Waiting room"Please wait" / "you're in line" holding page on high-demand itemsDetect, back off, retry later; not an every-request concern
Rate limitBursts of 429s from one exit IPSpace requests, lower concurrency, rotate IP
Datacenter biasFast challenges on plain datacenter rangesPrefer residential exits that blend with shopper traffic

A managed scraping API absorbs the fingerprint, the sensor execution, IP rotation, and rendering for you. For the proxy-side theory on why some IPs survive and others get burned, how to avoid getting your proxy blocked goes deep.

Set up the SparkProxy Scraping API

The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, browser rendering, and anti-bot layer. You send one request and get the rendered HTML back. For Best Buy, four parameters carry the weight:

  • render_js=true: runs the page in a real Chromium browser, which executes Akamai's sensor JavaScript, produces a legitimate fingerprint, and returns the hydrated HTML including the JSON-LD.
  • premium_proxy=true: routes through residential IPs, which blend with real shopper traffic where datacenter ranges get challenged.
  • stealth=true: adds extra anti-detection layers tuned for stacks like Akamai. It requires render_js=true.
  • country_code=US: sets a US exit, which matters because bestbuy.com is a US storefront and geo-mismatched requests draw friction.

The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.bestbuy.com/site/6565837.p?skuId=6565837" \
  --data-urlencode "render_js=true" \
  --data-urlencode "premium_proxy=true" \
  --data-urlencode "stealth=true" \
  --data-urlencode "country_code=US"

The full parameter list and response fields are in the Scraping API docs. If you are weighing this against running your own proxy pool for a target this well defended, web scraping API vs self-managed proxies lays out the trade-off honestly.

Scrape a single product by SKU

Start with one product. Wrap the request so every call carries the Best Buy-specific parameters, and give it a generous timeout since a rendered request drives a real browser.

import requests

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"

def fetch_url(target: str, country: str = "US") -> str:
    resp = requests.get(
        API,
        headers={"X-API-Key": API_KEY},
        params={
            "url": target,
            "render_js": "true",       # runs Akamai's sensor JS + returns hydrated HTML
            "premium_proxy": "true",   # residential IPs blend with shopper traffic
            "stealth": "true",         # extra anti-detection (needs render_js)
            "country_code": country,   # US storefront expects US traffic
        },
        timeout=90,
    )
    resp.raise_for_status()
    return resp.text

def product_url(sku: str) -> str:
    # The slug is cosmetic; Best Buy resolves the page from the skuId alone.
    return f"https://www.bestbuy.com/site/{sku}.p?skuId={sku}"

def fetch_product(sku: str) -> str:
    return fetch_url(product_url(sku))

You do not need the human-readable slug. Best Buy resolves the page from the skuId, so /site/.p?skuId= is enough and the API follows any redirect to the canonical URL. Next, read the JSON out of that HTML.

Parse price, SKU, availability, and rating

Two moves make Best Buy parsing reliable: pull every JSON-LD script tag, then select the one whose @type is Product instead of trusting position.

import json
from selectolax.parser import HTMLParser

def extract_product_ldjson(html: str) -> dict | None:
    """Return the JSON-LD block whose @type is Product, or None."""
    tree = HTMLParser(html)
    for node in tree.css('script[type="application/ld+json"]'):
        try:
            data = json.loads(node.text())
        except json.JSONDecodeError:
            continue
        # A page can ship a list of blocks or a single object.
        for block in (data if isinstance(data, list) else [data]):
            if isinstance(block, dict) and block.get("@type") == "Product":
                return block
    return None

selectolax is a fast C-backed HTML parser; install it with pip install selectolax. Once you have the Product block, mapping fields is a matter of safe .get() chains, because Best Buy omits keys rather than nulling them (a preorder item may lack a numeric price, mpn is not always present):

def norm_availability(value: str | None) -> str | None:
    """Turn a schema.org URL into a short status string."""
    if not value:
        return None
    tail = value.rstrip("/").rsplit("/", 1)[-1]  # ".../InStock" -> "InStock"
    return tail or None

def parse_product(html: str) -> dict | None:
    block = extract_product_ldjson(html)
    if not block:
        return None

    offer = block.get("offers", {}) or {}
    if isinstance(offer, list):           # some pages nest a list of offers
        offer = offer[0] if offer else {}
    brand = block.get("brand", {}) or {}
    rating = block.get("aggregateRating", {}) or {}

    return {
        "sku":          block.get("sku"),
        "title":        block.get("name"),
        "brand":        brand.get("name") if isinstance(brand, dict) else brand,
        "model":        block.get("mpn") or block.get("model"),
        "price":        offer.get("price"),
        "currency":     offer.get("priceCurrency"),
        "availability": norm_availability(offer.get("availability")),
        "in_stock":     norm_availability(offer.get("availability")) == "InStock",
        "rating":       rating.get("ratingValue"),
        "review_count": rating.get("reviewCount"),
        "image":        block.get("image"),
        "gtin":         block.get("gtin13") or block.get("gtin"),
    }

Because the values are typed, you get a float price and an int review count with no post-processing. Two habits pay off. Keep both the raw availability string and a derived in_stock boolean, so a later Best Buy status you have not seen yet (BackOrder, LimitedAvailability) does not silently read as "in stock." And store the gtin when it is present, because it is the field that lets you match the same physical product across Best Buy, Amazon, and Walmart when you build a cross-retailer feed.

Detect an Akamai block

This is the check that separates a scraper you can trust from one that quietly stores denial pages. Because Akamai's "Access Denied" response and the waiting-room page can arrive without a clean error status, raise_for_status() will not always catch them, and a missing JSON-LD block does not tell you why it is missing. Scan the body for the markers, and treat a missing Product block as a soft block too:

def is_blocked(html: str) -> bool:
    """Akamai / the waiting room can serve a page that isn't the product."""
    markers = (
        "access denied",
        "reference #",           # Akamai edge error reference id
        "you don't have permission",
        "please wait",           # virtual waiting room
        "you're in line",
        "/akam/",                # Akamai sensor path leaking into markup
    )
    lowered = html.lower()
    if any(m in lowered for m in markers):
        return True
    # A real product page always carries a Product JSON-LD block.
    return extract_product_ldjson(html) is None

Now a fetch is honest: it either returns a real product page or tells you it was blocked so you can retry. Pair this with stealth=true and residential rotation, and Akamai challenges become an occasional retry rather than a wall. Keep the is_blocked marker list in one place, since Akamai reworks its denial copy from time to time and you want a single spot to update.

Store-level availability and pickup

Here is the Best Buy-specific gotcha. The online price you read from JSON-LD is national, but pickup availability is per store. The same SKU can be "available today" at a store in one metro and "unavailable" fifty miles away, and that difference is often the whole point of the scrape for a fulfillment or inventory feed.

There are two clean ways to get store inventory, and they sit at different points on the compliance-versus-flexibility line.

The official Stores API. The Best Buy Developer API exposes store inventory: you query product availability for a SKU against a store, or list stores near a postal code, and get JSON back. When your access covers this, it is the sanctioned and least fragile route, no rendering and no anti-bot involved. Prefer it where it fits.

The site's fulfillment endpoint. The product page's "Check Stores" widget fires a background XHR to an internal fulfillment endpoint that returns availability keyed by store ID for a given location. You can reproduce that call, but the exact path and payload shape change, so capture the live request from your browser's network panel rather than hard-coding a URL you found in an old blog post. The stable idea is the shape of the answer: a JSON list of stores, each with a store ID and an in-stock flag for the SKU. Fetch it through the same rendered, residential-backed request so it inherits your anti-bot posture:

import json

def fetch_store_availability(endpoint: str) -> list[dict]:
    """
    `endpoint` is the fulfillment XHR you captured from the product page's
    'Check Stores' action (it encodes the skuId and a location). The response
    is JSON, so parse the body rather than the rendered HTML.
    """
    raw = fetch_url(endpoint)          # same render_js + residential request
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return []                      # blocked or non-JSON: treat as no data

    stores = []
    # Shape varies; walk to the per-store availability list you saw in devtools.
    for store in data.get("stores", data.get("locations", [])):
        stores.append({
            "store_id":   store.get("storeId") or store.get("id"),
            "city":       store.get("city"),
            "in_stock":   bool(store.get("inStoreAvailability")
                               or store.get("available")),
        })
    return stores

Whichever route you pick, standardize on a location per run (a fixed postal code or a fixed list of store IDs) and hold it constant, so a change in your data reflects a real inventory change and not a shift in which stores you asked about. If you are building a comparison or availability feed where that consistency is the entire game, datacenter proxies for price comparison websites covers the wider pattern.

Scrape ratings and reviews

The rating summary is easy: aggregateRating.ratingValue and reviewCount come straight out of the JSON-LD you already parsed, so a nightly job that only needs "how many stars, how many reviews" is done at that point.

Individual review text is a separate system. Best Buy's ratings and reviews are powered by Bazaarvoice, the same reviews platform many large retailers embed, and the review list loads from a Bazaarvoice-hosted widget rather than from the product HTML. That has two consequences. First, you will not find full review bodies in the JSON-LD, only the aggregate. Second, to pull individual reviews you request them from the Bazaarvoice display endpoint the page uses, paginated, keyed on the same Best Buy SKU. As with the fulfillment call, capture the live request from your network panel, because the client key and parameter names are specific to the deployment. The reliable pattern:

def parse_reviews(payload: dict) -> list[dict]:
    """
    `payload` is the JSON from the Bazaarvoice reviews request the page makes
    (it carries the SKU and a page offset). Map the fields you need.
    """
    rows = []
    for r in payload.get("Results", []):
        rows.append({
            "review_id":  r.get("Id"),
            "rating":     r.get("Rating"),          # 1 to 5
            "title":      r.get("Title"),
            "text":       r.get("ReviewText"),
            "submitted":  r.get("SubmissionTime"),
            "verified":   r.get("IsSyndicated") is False,
        })
    return rows

Two guardrails specific to reviews. Keep only what you need for analysis (rating distribution, recency, keyword signal) and do not republish full review text verbatim, since that is user-generated content with its own copyright and platform terms. And never attach reviewer names, profile links, or any other personal detail to your dataset. Aggregate sentiment is defensible; harvesting people is not.

Discover SKUs and scale the crawl

Everything so far assumes you have a SKU. To build a catalog you need to discover them, and the cleanest source is Best Buy's own search and browse pages. Search URLs follow /site/searchpage.jsp?st=, and category/browse pages expose paginated product grids. Fetch a results page through the same rendered request, then pull the SKUs. Best Buy exposes the SKU on each product tile (commonly in a data-sku-id attribute), so a light DOM pass is enough here, and you do not need JSON-LD for the listing step:

from selectolax.parser import HTMLParser
from urllib.parse import quote_plus

def search_url(query: str, page: int) -> str:
    return f"https://www.bestbuy.com/site/searchpage.jsp?st={quote_plus(query)}&cp={page}"

def parse_search_skus(html: str) -> list[str]:
    tree = HTMLParser(html)
    skus = []
    for tile in tree.css("[data-sku-id]"):
        sku = tile.attributes.get("data-sku-id")
        if sku and sku.isdigit():
            skus.append(sku)
    # de-dupe while preserving order
    seen, unique = set(), []
    for s in skus:
        if s not in seen:
            seen.add(s)
            unique.append(s)
    return unique

Once you have SKUs, scrape their product pages at volume with three habits: retry on soft blocks, back off so you do not spike a single IP, and keep concurrency modest. With a scraping API the provider rotates the exit IP per request, so your ceiling is your plan's rate limit, not the number of proxies you own. Five to fifteen workers is plenty for a target this defended.

import time
import random
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch_with_retry(sku: str, attempts: int = 3) -> str | None:
    for i in range(attempts):
        html = fetch_product(sku)
        if not is_blocked(html):
            return html
        time.sleep(2 ** i + random.random())   # exponential backoff + jitter
    return None

def scrape_catalog(skus: list[str], workers: int = 8) -> list[dict]:
    out = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(fetch_with_retry, s): s for s in skus}
        for fut in as_completed(futures):
            html = fut.result()
            if html is None:
                continue
            record = parse_product(html)
            if record:
                out.append(record)
    return out

def save_csv(rows: list[dict], path: str = "bestbuy_products.csv") -> None:
    if not rows:
        return
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)

The jitter matters more than it looks: random.random() staggers retries so a batch of failures does not retry in lockstep and re-trigger the same rate limit. Persist as you go rather than holding everything in memory, so a crash at SKU 40,000 does not cost you the first 39,999. For a running price tracker, add a scraped_at timestamp to each row and write to a database keyed on (sku, scraped_at), which gives you a clean time series where every price point is comparable. The same pattern generalizes to sibling retailers; the extraction differs but the crawl scaffolding is identical to how to scrape Walmart product data.

Frequently asked questions

FAQ

Scraping publicly accessible pages (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach Best Buy's Terms and Conditions, which prohibit automated collection. Check bestbuy.com/robots.txt, stick to public product data, avoid personal data, don't overload the servers, and get legal advice before any commercial use. Where it covers your need, Best Buy's official Developer API is the compliant alternative.

Akamai fingerprints your TLS handshake and validates an _abck cookie set only after client-side sensor JavaScript runs, so a raw HTTP request is flagged before the IP matters, and blocks can arrive as an "Access Denied" page rather than a clean error. Run a real browser and rotate residential IPs: with the SparkProxy Scraping API set render_js=true, premium_proxy=true, and stealth=true, then scan the body for block markers and retry.

Read them from the JSON-LD Product block that Best Buy embeds in every product page rather than scraping CSS selectors. Parse each