Field-tested against www.bestbuy.com on 2026-10-23 using a Browser Use cloud browser (plain HTTP was tried too and does not work, see below).
robots.txt (read in the browser on 2026-10-23; plain HTTP to it timed out like the rest of the site) does not disallow /site/searchpage.jsp, /site/<sku>.p or /product/<slug>/<code> for User-agent: *, but disallows any URL containing cp=1 (so cp=1 and cp=10-19), cp=20, or cp=6 through cp=9. The search block therefore loads page 1 with no cp parameter, pages 2-5 with cp=2 to cp=5, and stops there: pages 6-20 are disallowed. The product block uses /site/<sku>.p?skuId=<sku>, its redirect to /product/<slug>/<code>, and a search URL without cp.
Quick summary
- Plain HTTP is blocked (no status: curl's default HTTP/2 request failed with a stream error, exit 92; HTTP/1.1 and
http_gethit a 20 s read timeout with zero bytes); this skill uses a normal browser session. The officialapi.bestbuy.comreturns403 {"errorMessage":"We were unable to locate your API Key."}without a key; if you can register a key at developer.bestbuy.com, that API is the stable source (see "What does not work"). - Use a real browser. Search results (
/site/searchpage.jsp?st=<query>for page 1,&cp=<2-5>for pages 2-5) and product pages render normally in a cloud browser, logged out, no CAPTCHA seen. - Search results are client-rendered and lazy. Cards get prices only after they scroll into view, so scroll the page, then read the DOM cards (
.product-list-item[data-product-id]). 20-24 cards per page, sponsored cards included. - Product pages carry JSON-LD
Product(SKU, model, brand, price, availability, rating, review count, open-box offers). Full specs sit behind the "See all specifications" button; click it and read the sheet. - The blocks need a visible tab. A hidden (background) tab stayed on "Loading..." skeletons, also with focus emulation and with
document.visibilityStatefaked tovisible;activate_tab(current_tab())fixed it.new_tab()opens a hidden tab, so run the blocks in a dedicated or cloud browser withFOREGROUND_TAB = True, which brings the tab to the front. The flag defaults toFalseso that on your own Chrome a hidden tab raises a clear error instead of taking over the window you are using.
What works: search results across pages
Title, SKU, price, sale price, rating, review count, availability and URL for result pages 1-5 of gaming laptop, the pages robots.txt allows. Self-contained; opens one new tab.
# NEEDS A VISIBLE TAB: Best Buy never renders a hidden (background) tab, and new_tab() opens a
# hidden one. Run this in a dedicated or cloud browser with FOREGROUND_TAB = True, which brings
# the tab to the front. Left False, a hidden tab raises instead of taking over your window.
FOREGROUND_TAB = False
import json, math, time
from urllib.parse import quote_plus, urlparse, parse_qs
EXTRACT_CARDS = r"""(() => {
const money = s => { const m = (s || '').match(/\$([\d,]+\.\d{2})/); return m ? parseFloat(m[1].replace(/,/g, '')) : null; };
const txt = (el, sel) => { const e = el.querySelector(sel); return e ? e.innerText.trim() : null; };
return [...document.querySelectorAll('.product-list-item[data-product-id]')].map(card => {
const h3 = card.querySelector('h3');
const link = h3 ? h3.closest('a') : null;
const rnr = txt(card, '[data-testid="rnr-stats-link"] p') || '';
const rm = rnr.match(/rating,? ([\d.]+) out of 5 stars with ([\d,]+) review/i);
const price = money(txt(card, '[data-testid="price-block-customer-price"]'));
const regular = money(txt(card, '[data-testid="price-block-regular-price"]'));
// plp-add-to-cart-<sku>-label, plp-sold-out-<sku>-label, plp-unavailable-<sku>-label, plp_openbox-unavailable-<sku>-label
const btn = card.querySelector('[data-testid^="plp"][data-testid$="-label"]');
const button = btn ? btn.innerText.trim() : null;
const status = button === 'Add to cart' ? 'in_stock' : button === 'Sold Out' ? 'sold_out'
: button === 'Unavailable' ? 'unavailable' : /High demand product/.test(card.innerText) ? 'high_demand'
: button ? button.toLowerCase() : null;
return {
sku: card.getAttribute('data-product-id'),
title: h3 ? (h3.getAttribute('title') || h3.innerText).trim() : null,
url: link ? link.href.split('?')[0] : null,
price, // what the customer pays now
regular_price: regular, // "Comp. Value", only shown when discounted
on_sale: regular !== null && price !== null && price < regular,
savings: txt(card, '[data-testid="price-block-total-savings-text"]'),
rating: rm ? parseFloat(rm[1]) : null,
review_count: rm ? parseInt(rm[2].replace(/,/g, ''), 10) : (/Not yet reviewed/i.test(card.innerText) ? 0 : null),
availability: status,
button,
pickup: txt(card, '[data-testid^="plp-pickup-messaging-"]'),
shipping: txt(card, '[data-testid^="plp-shipping-messaging-"]'),
sponsored: !!card.closest('li.slContainer'),
};
});
})()"""
def safe_js(expr, tries=6, deadline=None):
# The page's main thread is busy for a few seconds after navigation and
# Runtime.evaluate can time out; retry instead of failing, but not past the deadline.
for _ in range(tries):
try:
return js(expr)
except (RuntimeError, TimeoutError):
if deadline is not None and time.monotonic() + 2 > deadline:
break
wait(2)
return None
class NotReady(RuntimeError):
"""The page did not render, or showed an error, in this attempt."""
def nav(url, href_ok, timeout):
# Best Buy keeps the main thread busy; give Page.navigate more than the 5 s default.
# A tab can hang mid-navigation: stop it and navigate once more (a second timeout raises).
end = time.monotonic() + timeout
left = lambda: max(1, min(30, end - time.monotonic())) # no CDP wait runs past the caller's deadline
# Record where the tab was and mark that document, so a reload of the same URL is told apart.
before = safe_js("(window.__bhOldDoc = true, location.href)", tries=1)
try:
cdp("Page.navigate", url=url, _response_timeout=left())
except TimeoutError:
cdp("Page.stopLoading", _response_timeout=left())
cdp("Page.navigate", url=url, _response_timeout=left())
here = None
while time.monotonic() < end: # read nothing until the tab is on a new document at the requested URL
wait(1)
here = safe_js("({href: location.href, old: !!window.__bhOldDoc,"
" status: (performance.getEntriesByType('navigation')[0] || {}).responseStatus})", tries=1) or here
if here and not here["old"] and href_ok(here["href"]):
if here["status"] in (403, 429): # a block: stop, never retry
raise RuntimeError(f"HTTP {here['status']} at {here['href']}: blocked, stop and back off")
return here["href"]
raise NotReady(f"navigation to {url} not confirmed within {timeout:.0f} s "
f"(was {before}, now {here['href'] if here else 'unreadable'})")
def ensure_visible():
if safe_js("document.visibilityState") == "visible":
return
if not FOREGROUND_TAB:
raise RuntimeError("tab is hidden and Best Buy does not render hidden tabs: run this in a dedicated or "
"cloud browser with FOREGROUND_TAB = True (brings the tab to the front), or show the tab yourself")
activate_tab(current_tab())
# robots.txt (User-agent: *) disallows any URL containing cp=1 (cp=1, cp=10-19), cp=20 and cp=6..9.
# Page 1 is the URL without cp; pages 2-5 are the only other pages it allows in sequence.
ROBOTS_PAGES = (1, 2, 3, 4, 5)
def search_url(query, page):
if page not in ROBOTS_PAGES:
raise ValueError(f"page {page}: robots.txt disallows cp={page} for User-agent: *")
url = f"https://www.bestbuy.com/site/searchpage.jsp?st={quote_plus(query)}"
return url if page == 1 else f"{url}&cp={page}"
def search_href_ok(query, page):
def ok(href): # Best Buy may add id=pcat17071 and reorder the parameters; st and cp must match
u = urlparse(href)
q = parse_qs(u.query)
return (u.netloc == "www.bestbuy.com" and u.path == "/site/searchpage.jsp"
and q.get("st") == [query] and q.get("cp", ["1"]) == [str(page)])
return ok
def load_search_page(query, page, deadline):
def check_time(step):
if time.monotonic() > deadline:
raise NotReady(f"out of time while {step}")
nav(search_url(query, page), search_href_ok(query, page), timeout=min(45, deadline - time.monotonic()))
for _ in range(20): # wait until product titles have rendered
wait(1.5)
if (safe_js("document.querySelectorAll('.product-list-item h3').length", deadline=deadline) or 0) > 0:
break
# The block never asks for a page past the end (see PAGE_SIZE), so this is an error page.
if safe_js("document.body.innerText.includes('unable to display products')", deadline=deadline):
raise NotReady("Best Buy showed \"unable to display products\"")
check_time("waiting for product titles")
else:
raise NotReady("no product titles rendered in 30 s")
# Cards and price blocks render lazily when they scroll into view.
for _ in range(25):
check_time("scrolling")
safe_js("window.scrollBy(0, 800)", deadline=deadline)
wait(0.6)
if safe_js("window.innerHeight + window.scrollY >= document.body.scrollHeight - 50", deadline=deadline):
break
for _ in range(3): # revisit cards whose title or price block has not rendered yet
missing = safe_js("""[...document.querySelectorAll('.product-list-item[data-product-id]')]
.filter(c => !c.querySelector('h3') || !c.querySelector('[data-testid="price-block-customer-price"]'))
.map(c => c.getAttribute('data-product-id'))""", deadline=deadline) or []
if not missing:
break
for sku in missing:
check_time("revisiting unrendered cards")
safe_js(f"document.querySelector('.product-list-item[data-product-id=\"{sku}\"]')?.scrollIntoView({{block: 'center'}})", deadline=deadline)
wait(0.8)
wait(1.5)
cards = safe_js(EXTRACT_CARDS, deadline=deadline)
if not cards:
raise NotReady("no product cards could be read")
# A card shell that never hydrated has a SKU but no h3 or link; sponsored cards render both too.
shells = [c["sku"] for c in cards if not (c["sku"] and c["title"] and c["url"])]
if shells:
raise NotReady(f"{len(shells)} of {len(cards)} cards still lack SKU, title or URL: {shells[:5]}")
# listResult.count is sometimes {} and searchResultsNum is "" or a numeric string: accept numbers only
meta = safe_js("""(() => {
const num = v => (v === '' || v === null || v === undefined || typeof v === 'object') ? null
: (Number.isFinite(Number(v)) ? Number(v) : null);
const lr = (window._BBY_DATA_ || {}).listResult || {}, d = window.dioPageData || {};
return {total: num(lr.count) ?? num(d.searchResultsNum), page: num(d.listPageNum) ?? num(lr.pageNumber)};
})()""", deadline=deadline) or {}
if meta.get("page") is None or meta.get("total") is None:
raise NotReady(f"Best Buy did not report the page number and result count ({meta})")
if meta["page"] != page: # also confirms the cp-less URL is page 1
raise RuntimeError(f"asked for page {page}, Best Buy rendered page {meta['page']}")
return cards, meta["total"], meta["page"]
RETRY_BUDGET_S = 120 # one deadline per result page, covering every attempt and every backoff
MIN_ATTEMPT_S = 40 # do not back off and retry unless an attempt this long still fits
def fetch_page(query, page):
deadline = time.monotonic() + RETRY_BUDGET_S
attempt = 0
while True:
attempt += 1
try:
return load_search_page(query, page, deadline)
except NotReady as e:
backoff = 15 * attempt
if time.monotonic() + backoff + MIN_ATTEMPT_S > deadline: # last attempt: raise now, no sleep
raise RuntimeError(f"page {page} failed after {attempt} attempt(s) within {RETRY_BUDGET_S} s: {e}. "
"Results so far are incomplete; do not report them as the full crawl") from None
print(f"page {page}: {e}; backing off {backoff} s, then retrying")
time.sleep(backoff)
# Best Buy paginates 18 organic results per page (24 cards = 18 organic + 6 sponsored; 1236 results
# gave a last-page link of 69 = ceil(1236 / 18)). The crawl stops at the last page the count implies.
PAGE_SIZE = 18
new_tab("about:blank")
ensure_visible()
query = "gaming laptop"
all_items, seen = [], set()
for page in ROBOTS_PAGES:
cards, total, shown_page = fetch_page(query, page) # raises rather than return a partial crawl
new = 0
for c in cards: # the same SKU can show up as a sponsored and an organic card
if c["sku"] and c["sku"] not in seen:
seen.add(c["sku"])
c["page"] = page
all_items.append(c)
new += 1
print(f"page {page} (site says page {shown_page}): {len(cards)} cards, {new} new, "
f"{sum(c['price'] is not None for c in cards)} priced, total results {total}")
if page >= math.ceil(total / PAGE_SIZE):
print(f"page {page} is the last page for {total} results: done")
break
if page != ROBOTS_PAGES[-1]:
time.sleep(3) # polite pacing between result pages
else:
print(f"stopped after page {ROBOTS_PAGES[-1]}: robots.txt disallows cp=6 to cp=20 for User-agent: *, "
f"so the rest of the {total} results are not fetched")
from collections import Counter
print(len(all_items), "unique products;", Counter(c["availability"] for c in all_items))
for item in all_items[:3]:
print(json.dumps(item))Real output (trimmed, 2026-10-23, latest fresh-browser run):
page 1 (site says page 1): 24 cards, 23 new, 24 priced, total results 1233
page 2 (site says page 2): 24 cards, 19 new, 24 priced, total results 1235
page 3 (site says page 3): 24 cards, 22 new, 24 priced, total results 1235
page 4 (site says page 4): 24 cards, 24 new, 24 priced, total results 1235
page 5 (site says page 5): 24 cards, 24 new, 24 priced, total results 1235
stopped after page 5: robots.txt disallows cp=6 to cp=20 for User-agent: *, so the rest of the 1235 results are not fetched
112 unique products; Counter({'in_stock': 110, 'sold_out': 1, 'unavailable': 1})
{"sku": "6687893", "title": "HP - Googlebook 14\" 3K OLED Touchscreen - Snapdragon X Elite - 16GB Memory - 512GB SSD - Atmospheric Blue",
"url": "https://www.bestbuy.com/product/hp-googlebook-14-3k-oled-touchscreen-snapdragon-x-elite-16gb-memory-512gb-ssd-atmospheric-blue/J3PPHVH9QX/sku/6687893",
"price": 1299, "regular_price": null, "on_sale": false, "savings": null, "rating": 4.5, "review_count": 33,
"availability": "in_stock", "button": "Add to cart", "pickup": "Pick up in 1 hour", "shipping": "Get it tomorrow \u2022 FREE",
"sponsored": true, "page": 1}
Earlier runs of the same crawl gave 99 and 104 unique products. Every card on all 5 pages, the 6 sponsored ones per page included, had a SKU, title and URL, so the block's card-shell check passed.
So 5 pages give about 100 unique products; the other ~1,100 results sit on pages robots.txt disallows for User-agent: * (cp=6 to cp=20). The permitted crawl of a query is capped at those 5 pages; for more, use the official API (see "What does not work"). Page 1 has no cp in the URL; the block checks that the page the site reports (dioPageData.listPageNum, else listResult.pageNumber) matches the page it asked for and raises if not, and it also raises when the site reports no page number or result count. Without cp, Best Buy rendered page 1 (listPageNum 1, the only pagination link pointing to cp=2), and cp=2 shared 3 of about 20 SKUs with it, in line with the cross-page repeats noted under Gotchas.
How the block ends and fails:
- Short result sets end on the count. Best Buy pages 18 organic results at a time (
gaming laptoppages 1, 2 and 5 each had 24 cards, 6 of them sponsored; 1,236 results gave a last-page link of 69, which is 1,236 / 18 rounded up). After each page the block stops once the page number reaches the reported total / 18, rounded up, so it never requests a page past the end. Forrtx 5090 founders editionthat is page 2 (cp=2reported 28 results in one run, 30 in another);cp=3there showed "unable to display products" with 0 cards. - Anything else is an error, not the end. "Unable to display products", no rendered titles, no readable cards, any card still missing its SKU, title or URL after the revisit passes, or a missing page number or count on a page the count says exists fails that attempt. Each page gets one 120 s deadline covering all attempts and backoffs (15 s, then 30 s); a retry starts only if at least 40 s would remain after its backoff, so the last attempt is followed by a raise, not a sleep. The error says the results so far are incomplete.
- Blocks are not retried. Before reading a page,
nav()records the tab's URL, waits until the tab is on a new document whoselocation.hrefis the requested URL (Best Buy may addid=pcat17071and reorder parameters, sostandcpare compared), and raises at once on HTTP 403 or 429 instead of retrying. Normal pages reported 200.
Counts vary run to run: an earlier run of pages 1-3 had 20 of 24 cards priced on page 1, and in another fresh-browser run page 1 came back with 14 of 24 priced and 9 cards that never rendered at all (title and url also null), even after the 3 revisit passes. The block now treats a page with any such card shell as not ready, so it reloads that page within the page's 120 s deadline and raises if the shells persist. A card whose price block alone never rendered is kept with price: null, so check the priced count per page. "Unavailable" and "Sold Out" cards still carry a price once they scroll into view: on an rtx 5090 search, all 5 "Unavailable" cards on pages 1-3 had a price (e.g. $1,799.99, $799.99). price is null only when a card never rendered its price block.
Search result fields
| Field | Source on the card | Notes |
|---|---|---|
sku | data-product-id attribute | Best Buy SKU, e.g. 6674559 |
title | h3[title] | Brand, " - ", name |
url | href of the link wrapping the h3 | /product/<slug>/<code>; sponsored cards append /sku/<sku> |
price | [data-testid="price-block-customer-price"] | Current price, float |
regular_price | [data-testid="price-block-regular-price"] ("Comp. Value: $1,924.99") | Only shown when discounted |
on_sale | price < regular_price | |
savings | [data-testid="price-block-total-savings-text"] | e.g. Save $750 |
rating, review_count | [data-testid="rnr-stats-link"] p ("Rating 4.6 out of 5 stars with 38 reviews") | Some items say "Syndicated rating, 4.76 out of 5 stars with 59 reviews on <vendor site>"; the regex takes both. "Not yet reviewed" gives 0 |
button, availability | [data-testid^="plp"][data-testid$="-label"] | Seen: "Add to cart" (plp-add-to-cart-<sku>-label), "Sold Out" (plp-sold-out-…), "Unavailable" (plp-unavailable-…, plp_openbox-unavailable-…), "Shop Open-Box". Collectibles marked "High demand product" have no such button (high_demand). null when no button rendered |
pickup, shipping | [data-testid^="plp-pickup-messaging-"], [data-testid^="plp-shipping-messaging-"] | e.g. "Pick up in 1 hour", "Get it tomorrow • FREE", "Unavailable"; depends on the store Best Buy assigns to your IP |
sponsored | card is inside li.slContainer | Dedupe on sku |
Page level: total result count is window._BBY_DATA_.listResult.count (sometimes an empty object), fallback window.dioPageData.searchResultsNum (an empty string until the page hydrates; a number on one page, a numeric string like "1234" on another). The block takes whichever parses as a number, else null. The current page number is dioPageData.listPageNum (a number) or listResult.pageNumber (a string such as "1"). window._BBY_DATA_.skus lists the page's SKUs in rank order with isSponsored flags before the cards finish rendering.
What works: product page with full specs
Price, regular price, availability, rating, open-box offers and every spec row. Reuses the current tab, takes the organic SKUs from a live laptop search, and scrapes product pages until it has shown one discounted and one full-price product (about 30-50 s per product). Call get_product(sku) with any SKU from the search block.
# NEEDS A VISIBLE TAB, like the search block: in a hidden tab the main price block never renders.
# Run it in a dedicated or cloud browser with FOREGROUND_TAB = True, which brings the tab to the
# front. Left False, a hidden tab raises instead of taking over your window.
FOREGROUND_TAB = False
import json, time
from urllib.parse import urlparse, parse_qs
PRODUCT_LD = r"""(() => {
for (const s of document.querySelectorAll('script[type="application/ld+json"]')) {
try { const d = JSON.parse(s.textContent); if (d['@type'] === 'Product') return d; } catch (e) {}
}
return null;
})()"""
SPEC_SHEET = r"""(() => {
const root = document.querySelector('[data-testid="brix-sheet-content"]');
if (!root) return null;
const out = {};
for (const h3 of root.querySelectorAll('h3')) {
const rows = {};
for (const row of h3.parentElement.querySelectorAll(':scope > div')) {
if (row.children.length < 2) continue;
const k = row.children[0].innerText.trim(), v = row.children[1].innerText.trim();
if (k) rows[k] = v;
}
out[h3.innerText.trim()] = rows;
}
return out;
})()"""
def safe_js(expr, tries=6):
for _ in range(tries):
try:
return js(expr)
except (RuntimeError, TimeoutError): # main thread busy right after navigation
wait(2)
return None
def nav(url, href_ok, timeout=45):
# Best Buy pages keep the main thread busy; give Page.navigate more than the 5 s default.
# A tab can hang mid-navigation: stop it and navigate once more (a second timeout raises).
end = time.monotonic() + timeout
left = lambda: max(1, min(30, end - time.monotonic()))
# Record where the tab was and mark that document, so a reload of the same URL is told apart.
before = safe_js("(window.__bhOldDoc = true, location.href)", tries=1)
try:
cdp("Page.navigate", url=url, _response_timeout=left())
except TimeoutError:
cdp("Page.stopLoading", _response_timeout=left())
cdp("Page.navigate", url=url, _response_timeout=left())
here = None
while time.monotonic() < end: # read nothing until the tab is on a new document at the requested URL
wait(1)
here = safe_js("({href: location.href, old: !!window.__bhOldDoc,"
" status: (performance.getEntriesByType('navigation')[0] || {}).responseStatus})", tries=1) or here
if here and not here["old"] and href_ok(here["href"]):
if here["status"] in (403, 429): # a block: stop, never retry
raise RuntimeError(f"HTTP {here['status']} at {here['href']}: blocked, stop and back off")
return here["href"]
raise RuntimeError(f"navigation to {url} not confirmed within {timeout:.0f} s "
f"(was {before}, now {here['href'] if here else 'unreadable'})")
def ensure_visible():
if safe_js("document.visibilityState") == "visible":
return
if not FOREGROUND_TAB:
raise RuntimeError("tab is hidden and Best Buy does not render hidden tabs: run this in a dedicated or "
"cloud browser with FOREGROUND_TAB = True (brings the tab to the front), or show the tab yourself")
activate_tab(current_tab())
def click_button_named(name):
try: # every CDP call here can exceed the 5 s default while the page is busy
nodes = cdp("Accessibility.getFullAXTree", _response_timeout=30)["nodes"]
for n in nodes:
if n.get("role", {}).get("value") == "button" and (n.get("name", {}).get("value") or "").strip().lower() == name.lower():
bid = n["backendDOMNodeId"]
cdp("DOM.scrollIntoViewIfNeeded", backendNodeId=bid, _response_timeout=30)
wait(0.5)
q = cdp("DOM.getBoxModel", backendNodeId=bid, _response_timeout=30)["model"]["content"]
click_at_xy(sum(q[0::2]) / 4, sum(q[1::2]) / 4)
return True
except (RuntimeError, TimeoutError):
pass # the caller retries
return False
# The main product's price block. Accessory, warranty, sponsored and "consider" carousels
# also use data-testid="price-block" and "price-block-regular-price", but sit outside this
# container, so never read those test ids page-wide.
MAIN_PRICE = '[data-testid="bestbuy-price-ui-price-experience"] [data-testid="price-block"]'
MAIN_PRICES = r"""(() => {
const money = e => { const m = e && e.innerText.match(/\$([\d,]+\.\d{2})/); return m ? parseFloat(m[1].replace(/,/g, '')) : null; };
const block = document.querySelector('%s');
if (!block) return null;
return {price: money(block.querySelector('[data-testid="price-block-customer-price"]')),
regular: money(block.querySelector('[data-testid="price-block-regular-price"]'))};
})()""" % MAIN_PRICE
def get_product(sku):
# /site/<sku>.p?skuId=<sku> redirects to the canonical /product/<slug>/<code> URL
nav(f"https://www.bestbuy.com/site/{sku}.p?skuId={sku}",
lambda h: urlparse(h).netloc == "www.bestbuy.com" and urlparse(h).path.startswith("/product/"))
ld, state = None, None
for _ in range(30): # JSON-LD arrives with the HTML; the page state and price block hydrate later
wait(1.5)
ld = ld or safe_js(PRODUCT_LD)
# a second isPDP entry for the same SKU can appear with priceType null; take the filled one
state = safe_js(f"((window._BBY_DATA_ || {{}}).skus || []).find(s => s.id === '{sku}' && s.isPDP && s.priceType) || null")
if ld and state and safe_js(f"!!document.querySelector('{MAIN_PRICE} [data-testid=\"price-block-customer-price\"]')"):
break
if not ld:
return None
if str(ld.get("sku")) != str(sku): # the redirect target must be the product that was asked for
raise RuntimeError(f"asked for SKU {sku}, the page is SKU {ld.get('sku')}")
state = state or {}
offers = ld.get("offers") or []
if isinstance(offers, dict):
offers = [offers]
new = next((o for o in offers if "NewCondition" in (o.get("itemCondition") or "")), offers[0] if offers else {})
# Main product only: its "Comp. Value" line shows when it is discounted, else regular is None
main = safe_js(MAIN_PRICES) or {}
regular = main.get("regular")
specs = None
for _ in range(3): # a click before hydration finishes does nothing, so retry
if not click_button_named("See all specifications"):
wait(2)
continue
for _ in range(8):
wait(1)
specs = safe_js(SPEC_SHEET)
if specs:
break
if specs:
break
try:
press_key("Escape") # close the sheet
except (RuntimeError, TimeoutError):
pass
rating = ld.get("aggregateRating") or {}
return {
"sku": ld.get("sku"),
"title": ld.get("name"),
"brand": (ld.get("brand") or {}).get("name"),
"model": ld.get("model"),
"url": ld.get("url"),
"price": new.get("price"),
"price_block_price": main.get("price"), # should equal price
"regular_price": regular, # None when the main product is not discounted
"on_sale": regular is not None and main.get("price") is not None and main["price"] < regular,
"availability": (new.get("availability") or "").rsplit("/", 1)[-1],
"price_type": state.get("priceType"), # "regular" / "onSale"; None if the state never hydrated
"button_state": state.get("primaryButtonState"), # e.g. "ADD_TO_CART"
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
"other_offers": [{"condition": o.get("description"), "price": o.get("price")} for o in offers if o is not new],
"specs": specs,
}
ensure_visible()
# Pick a SKU from a live search so this block does not depend on a hard-coded product.
nav("https://www.bestbuy.com/site/searchpage.jsp?st=laptop",
lambda h: urlparse(h).path == "/site/searchpage.jsp" and parse_qs(urlparse(h).query).get("st") == ["laptop"]
and "cp" not in parse_qs(urlparse(h).query))
skus = []
for _ in range(20):
wait(1.5)
skus = safe_js("[...new Set(((window._BBY_DATA_ || {}).skus || []).filter(s => s.id && !s.isSponsored).map(s => s.id))]") or []
if len(skus) >= 5: # the list fills in as the page hydrates
break
print("organic SKUs on page:", skus[:5])
# Scrape organic SKUs until there is one discounted and one full-price product to show both cases.
shown = set()
for sku in skus[:12]:
product = get_product(sku)
if not product:
print(sku, "no product data")
continue
specs = product.pop("specs") or {}
print(sku, "on_sale", product["on_sale"], "price", product["price"], "price block", product["price_block_price"],
"regular", product["regular_price"], "price_type", product["price_type"])
if product["on_sale"] in shown:
continue
shown.add(product["on_sale"])
print(json.dumps(product, indent=1))
print(sum(len(v) for v in specs.values()), "spec rows in", len(specs), "sections")
print(json.dumps({k: specs[k] for k in list(specs)[:3]}, indent=1))
if shown == {True, False}:
breakReal output (trimmed, 2026-10-23, latest fresh-browser run):
organic SKUs on page: ['6667483', '12756229', '12341769', '6667482', '6681674']
6667483 on_sale True price 209.99 price block 209.99 regular 349.99 price_type onSale
{
"sku": "6667483",
"title": "HP - 14\" Laptop - Intel N150 Processor - 4GB Memory - 128GB Storage - Pale Rose Gold",
"brand": "HP", "model": "14-dq6105dx",
"url": "https://www.bestbuy.com/product/hp-14-laptop-intel-n150-processor-4gb-memory-128gb-storage-pale-rose-gold/JJGW3FGF9T",
"price": 209.99, "price_block_price": 209.99, "regular_price": 349.99, "on_sale": true,
"availability": "InStock", "price_type": "onSale", "button_state": "ADD_TO_CART", "rating": 4.4, "review_count": 135,
"other_offers": [{"condition": "Open-Box Excellent", "price": 197.99}, {"condition": "Open-Box Good", "price": 191.99},
{"condition": "Open-Box Fair", "price": 184.99}]
}
83 spec rows in 17 sections
{"Key Specs": {"Screen Type": "TN", "Screen Size": "14 inches", "Processor Model": "Intel Processor N150", ...},
"General": {"Brand": "HP", "Model Number": "14-dq6105dx", "Color": "Pale Rose Gold", "Year of Release": "2026"},
"Display": {"Display Type": "LED", "Screen Size": "14 inches", "Refresh Rate": "60Hz", "Brightness": "250 nits", ...}}
12756229 on_sale True price 337 price block 337 regular 399.99 price_type onSale
12341769 on_sale True price 829 price block 829 regular 1199.99 price_type onSale
6667482 on_sale True price 199.99 price block 199.99 regular 349.99 price_type onSale
6681674 on_sale True price 539.99 price block 539.99 regular 699.99 price_type onSale
6615874 on_sale False price 699 price block 699 regular None price_type regular
{
"sku": "6615874",
"title": "Apple - MacBook Neo 13-inch Laptop - A18 Pro chip with 6‑core CPU and 5‑core GPU - 8GB Memory - 256GB SSD - Indigo",
"brand": "Apple", "model": "MHFF4LL/A",
"price": 699, "price_block_price": 699, "regular_price": null, "on_sale": false,
"availability": "InStock", "price_type": "regular", "button_state": "ADD_TO_CART", "rating": 4.9, "review_count": 1470, ...
}
52 spec rows in 16 sections
...
regular_price is null for the full-price MacBook even though its page has 6+ other price-block-regular-price elements in carousels (31 after scrolling to the bottom).
Product page fields
| Field | Source | Notes |
|---|---|---|
sku, title, brand, model, url | JSON-LD Product: sku, name, brand.name, model, url | In the initial HTML; there are also BreadcrumbList and video ItemList JSON-LD blocks. get_product first confirms the tab landed on a /product/ URL, then raises if the JSON-LD sku is not the SKU it asked for |
price, availability | JSON-LD offers[] entry whose itemCondition is NewCondition | availability is the schema.org suffix, e.g. InStock |
other_offers | other JSON-LD offers[] | Open-Box Excellent / Good / Fair |
price_block_price, regular_price | [data-testid="price-block-customer-price"] and [data-testid="price-block-regular-price"] inside the main product's [data-testid="bestbuy-price-ui-price-experience"] [data-testid="price-block"] | regular_price (the main block's "Comp. Value: $419.00" line) is None when the main product is not discounted. price_block_price matched JSON-LD price on all 6 laptops checked. Never read these test ids page-wide: warranty, sponsored, "consider" and other product carousels reuse them, and on the undiscounted $699.00 MacBook the first page-wide match was another card's "The price was $419.00". The container appears twice (top and a lower duplicate), both for the main product |
on_sale | regular_price present and above price_block_price | Works even when price_type is None |
price_type, button_state | window._BBY_DATA_.skus entry with isPDP: true and a non-null priceType (priceType, primaryButtonState) | Hydrates 10-20 s after JSON-LD, and a second isPDP entry with priceType: null can show up. In an earlier run, on 1 of 6 laptops (13214744) it never hydrated within the 45 s wait, so both were None; in the latest run all 7 laptops hydrated. Same entry has unitPrice, altSellerPriceMin/Max, userGeneratedContent.ratingAverage/reviewsCount |
rating, review_count | JSON-LD aggregateRating.ratingValue / reviewCount | |
specs | "See all specifications" sheet [data-testid="brix-sheet-content"]: each h3 is a section, each direct child div is a row of label and value | Section "Other" holds UPC, BSIN (the code at the end of the product URL) and SKU. 52, 83 and 84 rows on the three laptops checked. specs is None when the sheet did not open after 3 clicks: in one of two later fresh-browser runs that happened on the MacBook (0 spec rows), while the other run got its 52 rows |
URL patterns
| URL | Observed |
|---|---|
/site/searchpage.jsp?st=<url-encoded query> | Search results, page 1; Best Buy sometimes adds id=pcat17071 itself |
/site/searchpage.jsp?st=<url-encoded query>&cp=<2-5> | Pages 2-5. robots.txt disallows cp=1 (and cp=10-19), cp=6 to cp=9 and cp=20 for User-agent: *; the site's own pagination links also use cp |
/site/<sku>.p?skuId=<sku> | Redirects to the canonical /product/<slug>/<code> page |
/product/<slug>/<code> | Product page |
cp past the last page | "We're unable to display products at the moment", 0 cards. Example: rtx 5090 founders edition reported 24 results on page 1 and 30 on cp=2 (12 cards), and cp=3 showed the message with 0 cards |
Gotchas
- Background tab means endless skeletons. With
document.visibilityState == "hidden"the search page stayed at "Main Content / Loading..." with 0 titles and 0 prices for 24 s (rechecked 2026-10-23 in a fresh cloud browser). It stayed that way withEmulation.setFocusEmulationEnabled(enabled=True), and also withdocument.visibilityStateanddocument.hiddenfaked tovisible/falseby a script added before navigation. Afteractivate_tab(current_tab()), titles rendered within 2 s. A hidden product page got its JSON-LD and_BBY_DATA_entry but no main price block in 30 s; it appeared within 2 s of activating the tab. So a visible tab is required, not optional.activate_tabbrings the tab to the front, which is why the blocks call it only when you setFOREGROUND_TAB = True(do that in a dedicated or cloud browser); on your own Chrome leave itFalseand show the tab yourself. Keep one visible working tab and close extras. - Busy main thread. Right after navigation,
Runtime.evaluateandPage.navigatesometimes exceeded browser-harness's 5 s IPC default (Runtime.evaluate timed out,Page.navigate timed out after 5s).DOM.getBoxModeltimed out the same way while clicking "See all specifications". The blocks pass_response_timeout=30to those CDP calls and retry onRuntimeErrororTimeoutError(browser-harness raises aTimeoutErrorsubclass for IPC timeouts). Once a tab hung mid-navigation (title stuck as the URL, every evaluate timing out);cdp("Page.stopLoading")plus a new navigate recovered it, sonav()does exactly that whenPage.navigatetimes out, and raises if the retry times out too. In a later probe a tab stayed hung even afterPage.stopLoadingand a navigate toabout:blank(everyRuntime.evaluatetimed out); the search block bounds every wait by its page deadline, so a tab like that ends in an error instead of an endless wait. - Lazy price blocks. One top-to-bottom scroll left some cards unpriced; scrolling each unpriced card into view filled them in (24 of 24 on pages 2 and 3). The retry includes "Unavailable" and "Sold Out" cards, which do get a price block when in view; the loop gives up after 3 passes, so a card that never renders one keeps
price: null. - Hydration order on product pages. JSON-LD is there first; the main price block and
_BBY_DATA_.skusarrive later (theisPDPentry took 12-18 s in one trace, and once did not appear within 20 s). A "See all specifications" click before hydration did nothing, hence the wait and retry. - Card shells mean the product API is failing. In 2 fresh cloud browsers on 2026-10-23, page 1 of
gaming laptoploaded but 17 and 15 of its 24 cards stayed shells: anh3with no title, a link with nohref, "Not Yet Reviewed", "See price in cart" and no price block. A reload with Network events on showed 149 of the page's 156/gateway/graphqlrequests failing withnet::ERR_HTTP2_PROTOCOL_ERROR(7 returned 200), the same HTTP/2 stream error plain curl gets. The search block raisedpage 1 failed after 1 attempt(s) within 120 s: 15 of 24 cards still lack SKU, title or URL; revisiting that many shells used enough of the 120 s that no second attempt fit. Treat it like a block: stop, back off, and use a new session later (the next session, about 15 minutes later, rendered every card on pages 1-4, and the one after it crawled all 5 pages). - Page 5 can run out of time. In 2 other fresh browsers that day, pages 1-4 completed and page 5 used its whole 120 s in the first attempt, raising
out of time while revisiting unrendered cardsonce andout of time while waiting for product titlesonce. In the second case everyRuntime.evaluateon the tab then timed out (a hung tab, see "Busy main thread"). The block raises rather than return 4 pages as the full crawl; rerun in a new browser. - Store and ZIP come from your IP. The header shows a nearby store; pickup and shipping text depend on it. A different exit location can change those fields and sometimes ranking.
- Sponsored duplicates. One SKU appeared as both a sponsored and an organic card on the same page, and a few SKUs repeat across
cppages. Dedupe onsku. - Selector drift. The site is a Next.js app with utility-class styling. Rely on
data-testid,data-product-id, JSON-LD and_BBY_DATA_; avoid class names other thanproduct-list-itemandslContainer. - No consent banner appeared for the US cloud browser, so there is nothing to dismiss.
- Pacing. About 40 page loads over roughly an hour from one session produced no block page or CAPTCHA. The search block sleeps 3 s between pages; do not go faster across many queries.
- Page depth. robots.txt caps a
User-agent: *crawl of one query at 5 pages (see the top).search_url()raisesValueErrorfor any other page number rather than skipping ahead.
What does not work
http_get, curl or urllib againstwww.bestbuy.comsearch or product pages: no response before timeout, so plain HTTP is blocked; this skill uses a normal browser session.api.bestbuy.com/v1/products(...)without an API key: HTTP 403{"errorMessage":"We were unable to locate your API Key."}. This is Best Buy's official, structured product API: developer.bestbuy.com has a "Get API Key" signup, and the documented form ishttps://api.bestbuy.com/v1/products(search=laptop)?format=json&apiKey=<your key>(the docs warn about per-second query limits). If you can register for a key, prefer it over scraping; it was not tested here because no key was used.- Embedded data on search pages: no
__NEXT_DATA__, no JSON-LD, the Apollo SSR transport was empty andself.__next_fcontained noskuId. Prices and fulfillment load through client-side POSTs to/gateway/graphql(operations such asPriceBlockInit_Compact_GetProduct); those were not replayed here, so read the rendered DOM. - Specs in the initial product page DOM: the UPC was absent from
document.body.innerTextuntil the sheet was opened.