How to Scrape Newegg Product Data
Scrape Newegg product data the right way: pull prices, specs, stock, and reviews from item-number URLs and JSON-LD, then clear Akamai with a Scraping API.

Newegg looks like an easy target until your first requests.get returns a 403 and a page that says "Access Denied." To scrape Newegg product data reliably, prices, specs, stock levels, and reviews, you need three things most tutorials skip: how Newegg's item-number URLs really work, where the data actually lives (a JSON-LD block, not the buy box you see on screen), and how to get a real browser past Akamai's bot checks. This guide covers all three with working Python you can run today, plus the traps that eat an afternoon: combo pages that poison your price series, reviews that lazy-load, and exit IPs that hand you the wrong currency.
Why Newegg Is Harder Than a Plain HTML Scrape
Three things separate Newegg from a static catalog you can pull with curl.
First, the bot defense. Newegg sits behind Akamai Bot Manager, which fingerprints the TLS handshake and runs a JavaScript sensor that collects browser signals (canvas hash, WebGL renderer, timing, navigator properties) into an encrypted sensor_data payload. A plain HTTP client never runs that JavaScript, so it fails the check and gets a 403 with an "Access Denied" or "Pardon Our Interruption" page instead of the product. You need a real browser and an IP that does not look like a datacenter.
Second, the URL model. Newegg identifies products by an item number, not a clean numeric SKU in the path. The human-readable slug in the URL is decoration. Only the item number is canonical, and getting this right is what makes deduplication and re-fetching stable.
Third, the data itself is not where you think. The visible price, stock badge, brand, and rating are all duplicated inside a JSON-LD Product block in the page source. Parsing that block is far more durable than chasing CSS classes that Newegg reshuffles on every redesign. Most scraper tutorials scrape the DOM and break monthly. This one reads the structured data first.
If you also pull data from other electronics retailers, the same pattern applies to scraping Best Buy product data and scraping Amazon product data; the anti-bot layer and JSON-LD trick carry across all three.
Newegg URL and Item-Number Structure
A modern Newegg product URL looks like this:
https://www.newegg.com/some-product-name-slug/p/N82E16814137785
The part after /p/ is the item number. That is the only piece that matters. The slug before /p/ is cosmetic, and you can drop it entirely:
https://www.newegg.com/p/N82E16814137785
That short form resolves to the same product. Store the item number as your primary key and rebuild the URL from it. Never dedup on the full slug URL, because Newegg changes slugs and the same product can appear under several of them.
Item numbers come in two shapes, and the shape tells you who sells it:
| Prefix | Example | Meaning |
|---|---|---|
| `N82E16` | `N82E16814137785` | First-party item sold and shipped by Newegg |
| `9SIA` | `9SIABPKJT41158` | Newegg Marketplace item from a third-party seller |
The 9SIA prefix is the tell that a third-party seller sets the price, which matters a lot for price monitoring because marketplace prices move independently of Newegg's own. Capture the seller regardless.
Here is a small helper that extracts the item number from any Newegg URL and normalizes it to the short canonical form:
import re
ITEM_RE = re.compile(r"/p/([A-Z0-9]{10,20})", re.IGNORECASE)
def newegg_item_number(url: str) -> str | None:
m = ITEM_RE.search(url)
return m.group(1).upper() if m else None
def canonical_url(item: str) -> str:
# The slug before /p/ is cosmetic; the item number is the real identifier.
return f"https://www.newegg.com/p/{item}"
print(newegg_item_number(
"https://www.newegg.com/gigabyte-geforce-rtx/p/N82E16814137785"
))
# N82E16814137785
The older query-string format (Product/Product.aspx?Item=N82E16814137785) still redirects, so a regex on /p/ plus a fallback on Item= covers both eras.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
What Data Lives on a Newegg Product Page
Before writing selectors, know what you can pull and which source is most reliable for each field. Prefer the JSON-LD block wherever it carries the value, and fall back to the rendered DOM only for fields the structured data omits.
| Field | Where it appears on the page | Most reliable source |
|---|---|---|
| Price | Buy box and `offers.price` | JSON-LD |
| Currency | Buy box and `offers.priceCurrency` | JSON-LD |
| Stock status | Buy box button and `offers.availability` | JSON-LD |
| Seller ("Sold by") | Buy box line and `offers.seller` | JSON-LD |
| Rating and review count | Egg icons and `aggregateRating` | JSON-LD |
| Brand, SKU, MPN | Spec area and top-level JSON-LD | JSON-LD |
| Full specifications | Specs tab table | DOM (`th`/`td` rows) |
| Individual reviews | Reviews tab (lazy-loaded, paginated) | Rendered DOM |
| Item number | URL and `sku` | URL |
The pattern is consistent: the summary numbers live in structured data, and only the long-form content (the spec grid and the review list) needs DOM parsing. Build your scraper around that split and it survives redesigns.
Fetch a Product Page Past Akamai
Start by seeing the block for yourself so you know what a failure looks like:
import requests
r = requests.get(
"https://www.newegg.com/p/N82E16814137785",
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
)
print(r.status_code) # 403
print("Access Denied" in r.text) # True
Rotating the User-Agent will not fix this. Akamai wants a browser that executes its sensor script from an IP with a clean reputation. You can stand up your own headless Chromium with a residential proxy (the web scraping with Playwright and proxies guide walks that path), or you can hand the whole browser-plus-IP problem to a Scraping API and get HTML back.
The SparkProxy Scraping API runs a real browser and routes through residential exits, which is exactly the combination Akamai demands. Send the target URL and the flags that matter for Newegg:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
def fetch_newegg(url: str) -> str:
r = requests.get(
API,
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": url,
"render_js": "true", # execute Akamai's JS sensor in a real browser
"premium_proxy": "true", # residential exit IP, not a flagged datacenter range
"country_code": "us", # US price and stock; Newegg localizes by geography
"stealth": "true", # extra fingerprint hardening (+5 credits)
},
timeout=90,
)
r.raise_for_status()
return r.text
html = fetch_newegg("https://www.newegg.com/p/N82E16814137785")
Authentication is the X-API-Key header carrying a key from your dashboard. Here is why each flag earns its place:
| Parameter | Value | Why it matters for Newegg |
|---|---|---|
| `render_js` | `true` | Akamai's sensor is JavaScript; a plain fetch never runs it and gets 403 |
| `premium_proxy` | `true` | Datacenter IP ranges get flagged fast; residential IPs pass |
| `country_code` | `us` | A non-US exit can return a different currency or a localized store |
| `stealth` | `true` | Adds anti-detection layers for the harder Bot Manager configurations |
| `wait_for` | CSS selector | Await a lazy-loaded section (reviews, specs) before capture |
| `json_response` | `true` | Return `status_code`, `credits_used`, and `body` in one envelope |
On pricing, a browser render through a residential IP is the expensive tier, so batch deliberately and cache aggressively. Pull the JSON-LD once per product per run and derive price, stock, rating, and seller from that single fetch rather than hitting the page again per field.
Parse Price, Stock, and Seller from JSON-LD
Newegg's product pages ship a block with @type: Product. Find it, and you have the price, availability, seller, and rating without touching a single fragile CSS class.
import json
from bs4 import BeautifulSoup
def product_jsonld(html: str) -> dict | None:
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except json.JSONDecodeError:
continue
blocks = data if isinstance(data, list) else [data]
for block in blocks:
if isinstance(block, dict) and block.get("@type") == "Product":
return block
return None
Once you have the Product block, pull the fields into a flat row. The offers object can be a dict or a list, and availability is a schema.org URL like https://schema.org/InStock, so take the last path segment:
def parse_product(block: dict) -> dict:
offers = block.get("offers", {})
if isinstance(offers, list):
offers = offers[0] if offers else {}
brand = block.get("brand")
if isinstance(brand, dict):
brand = brand.get("name")
seller = offers.get("seller")
if isinstance(seller, dict):
seller = seller.get("name")
rating = block.get("aggregateRating") or {}
return {
"name": block.get("name"),
"brand": brand,
"sku": block.get("sku"),
"mpn": block.get("mpn"),
"price": offers.get("price"),
"currency": offers.get("priceCurrency"),
"availability": (offers.get("availability") or "").rsplit("/", 1)[-1],
"seller": seller,
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
}
Newegg's availability values and buy-box labels do not line up one to one, so normalize them to your own enum. This mapping keeps your price history clean when a product flips between states:
| Newegg label / schema.org value | Normalize to |
|---|---|
| "In stock" / `InStock` | `in_stock` |
| "Out of Stock" / `OutOfStock` | `out_of_stock` |
| "Auto Notify" | `out_of_stock_notify` |
| "Coming Soon" / `PreOrder` | `preorder` |
| "Backordered" / `BackOrder` | `backorder` |
| Item page 404 or "Deactivated" | `delisted` |
The delisted state is worth tracking on its own. When a 9SIA marketplace item disappears, that is a seller pulling the listing, not a stockout, and conflating the two skews any availability metric you report.
Extract the Specs Table
Specifications are the one place you have to parse the DOM, because they are not in the JSON-LD. Newegg renders them as a set of horizontal tables inside the specs tab, each row a th label paired with a td value.
def parse_specs(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
specs = {}
# Newegg groups specs into several tables (General, Details, Warranty...).
for table in soup.select("div.tab-pane table.table-horizontal"):
for row in table.select("tr"):
key = row.find("th")
val = row.find("td")
if key and val:
specs[key.get_text(strip=True)] = val.get_text(" ", strip=True)
return specs
# {'Brand': 'GIGABYTE', 'Series': 'GeForce RTX 40', 'Chipset': 'GeForce RTX 4070', ...}
Class names on Newegg drift between redesigns, so anchor on the structure, not the exact class. The reliable signal is a table whose rows are th/td pairs sitting inside a tab panel. If table.table-horizontal stops matching after a Newegg update, widen the selector to any table inside the product-details container and keep the th/td row logic. The shape outlives the styling.
Specs are also where categories diverge. A GPU exposes Chipset and Memory Size; a power supply exposes Wattage and Efficiency Certification. Do not hard-code a fixed schema. Store specs as a key-value map and let each product category populate whatever keys it has.
Scrape Ratings and Reviews (the Egg System)
Newegg shows ratings as eggs rather than stars, its own long-running 5-egg scale. Do not try to count egg SVGs in the markup. The numeric value you want is already in the JSON-LD aggregateRating, which parse_product above pulled as rating and review_count. That is the summary done, with no extra request.
Individual reviews are a different job. They live in the reviews tab, they lazy-load after the main paint, and they paginate. Two approaches:
If you only need the score and volume, stop here. The aggregateRating gives you ratingValue and reviewCount, which is enough for most price-and-rating monitoring.
If you need the review text (for review monitoring and sentiment analysis), render the reviews tab and wait for the list to attach before capturing. The wait_for parameter blocks until a selector appears, which stops you from grabbing an empty container:
def fetch_reviews(item: str, page: int = 1) -> str:
url = f"https://www.newegg.com/p/{item}?PageNumber={page}"
r = requests.get(
API,
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": url,
"render_js": "true",
"premium_proxy": "true",
"wait_for": "div.comments-cell", # await the review list before capture
},
timeout=90,
)
r.raise_for_status()
return r.text
def parse_reviews(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
out = []
for cell in soup.select("div.comments-cell"):
title = cell.select_one(".comments-title-content")
body = cell.select_one(".comments-content")
rating = cell.select_one(".rating") # egg count is in an aria-label or class
out.append({
"title": title.get_text(strip=True) if title else None,
"body": body.get_text(" ", strip=True) if body else None,
"rating": rating.get("aria-label") if rating else None,
})
return out
Selectors in the review list drift more than most, so treat them as a starting point and verify against the current markup. Walk pages until a request returns zero review cells, then stop. Respect the volume: a product with 4,000 reviews is 200-plus rendered page fetches, and each one is a browser render. Decide upfront whether you need every review or a recent sample, because the cost difference is large.
Handle Combo and Bundle Deals
Newegg's combo deals are the single biggest source of dirty data in a Newegg price feed, and almost no tutorial mentions them. A combo bundles a main product with add-ons (a motherboard with a CPU, a GPU with a power supply) at a discounted total. The combo has its own identifier and its own page, and its price is the bundle price, not the price of the item you care about.
If you scrape a combo page thinking it is a single product, you record a price that is higher than one item and lower than the sum, and your price history for that item is now corrupt. Detect combos and route them separately:
def is_combo(url: str, block: dict | None) -> bool:
# URL markers are the strongest signal.
if "/Combo/" in url or "ComboDeal" in url.replace(" ", ""):
return True
# A single-item Product block carries an sku; combo pages usually do not.
if block and block.get("@type") == "Product" and not block.get("sku"):
return True
return False
Combo detection by URL is the strongest signal, since combo pages sit on a distinct path. The missing-sku heuristic is a backup for cases where you arrive at a combo through a redirect. When is_combo returns true, either skip the record or store it in a separate combos table with its own bundle price, so it never contaminates single-item price series.
Scale Up: Rotation, Rate Limits, and Retries
One product is a demo. A catalog is the real job, and it needs retry logic, backoff, and restraint. Wrap the fetch-and-parse in a loop that treats a missing JSON-LD block as a soft block (it usually means you got a challenge page, not the product) and retries with exponential backoff and jitter:
import time
import random
def scrape_catalog(items: list[str]) -> list[dict]:
rows = []
for item in items:
url = canonical_url(item)
for attempt in range(4):
try:
html = fetch_newegg(url)
block = product_jsonld(html)
if not block:
raise ValueError("no Product JSON-LD (likely a block or challenge page)")
row = parse_product(block)
row["item"] = item
row["specs"] = parse_specs(html)
rows.append(row)
break
except Exception as err:
backoff = min(2 ** attempt, 15) + random.uniform(0, 1)
print(f"{item}: {err}; retry in {backoff:.1f}s")
time.sleep(backoff)
time.sleep(random.uniform(1.5, 4.0)) # pace between products; do not hammer
return rows
Two decisions keep this reliable. Rotate the exit IP on every attempt, which the Scraping API does for you server-side, so a flagged IP never gets a second try on the same product. Add jitter to the delay so a batch that all hits a 429 at once does not retry in lockstep and re-trigger the limit. If you would rather structure the extraction server-side instead of parsing HTML yourself, request the JSON envelope and read the metadata alongside the body:
params = {
"url": canonical_url("N82E16814137785"),
"render_js": "true",
"premium_proxy": "true",
"json_response": "true", # wrap body + metadata in one JSON response
}
resp = requests.get(API, headers={"X-API-Key": "YOUR_API_KEY"},
params=params, timeout=90).json()
print(resp["status_code"], resp["credits_used"])
html = resp["body"]
The API also supports extract_rules for CSS-based structured extraction, which returns parsed fields directly instead of HTML. Check the Scraping API docs for the exact selector schema before wiring it in. For high-volume price runs across many retailers, the concurrency and per-IP budgeting patterns in how to avoid getting your proxy blocked apply directly.
A realistic cadence for daily price monitoring: pull the JSON-LD once per item per day, skip the specs re-parse unless the SKU changed (specs rarely move), and only render the reviews tab on a weekly cycle. That keeps your credit spend proportional to the data that actually changes.
Stay Within Newegg's Terms and the Law
Scraping a public retail page is common, but it is not consequence-free, and a technical guide owes you the honest version.
Newegg's Terms of Use restrict automated access, and its robots.txt disallows crawling on certain paths. The only sanctioned programmatic route into Newegg's catalog is the Newegg Marketplace API, and that is built for sellers managing their own listings, not for buyers pulling competitor prices. There is no public buyer-facing product API, which is precisely why people scrape.
A few ground rules keep a scraping project defensible:
- Only public pages. Never scrape behind a login, and never touch account or checkout flows. US case law (hiQ Labs v. LinkedIn) has treated scraping of public data more favorably than access to authenticated systems, but that distinction only protects you if you stay on the public side of it.
- Facts, not creative content. Prices, stock, and specs are facts and are not copyrightable. Review text, product photography, and editorial copy are somebody's copyrighted work. Store what you need to analyze; do not republish reviews or images as your own.
- No personal data. Reviewer usernames and profiles are personal data under regimes like the GDPR and CCPA. If you collect reviews, avoid retaining identifiers you do not need.
- Be a polite client. Respect
robots.txt, keep request rates reasonable, and do not degrade the site. Aggressive scraping is what turns a gray area into a cease-and-desist.
None of this is legal advice. If you are scraping at commercial scale or reselling the data, have a lawyer review your plan. For the broader use case around monitoring competitor catalogs responsibly, see how ecommerce companies use proxies for competitive intelligence.
Frequently asked questions
FAQ
No. Newegg publishes a Marketplace API, but it is built for third-party sellers to manage their own listings, inventory, and orders, not for pulling arbitrary product prices and specs as a buyer. There is no public buyer-facing product API, so scraping the public product pages is the practical route to that data.
Because Newegg runs Akamai Bot Manager, which requires the client to execute a JavaScript sensor and to come from an IP with a clean reputation. A plain HTTP request (Python requests, curl) never runs that script and gets a 403 with an "Access Denied" or "Pardon Our Interruption" page. Use a real browser render through a residential IP, which is what render_js=true plus premium_proxy=true provides.
The item number is the segment after /p/ in the product URL, for example N82E16814137785. Numbers starting with N82E16 are sold by Newegg directly; numbers starting with 9SIA are Newegg Marketplace items from third-party sellers. It is the canonical product key, so store it and rebuild URLs as newegg.com/p/ rather than keeping the full slug.
Render the page in a real browser through a residential exit IP, pull the price from the JSON-LD offers.price field instead of a CSS class, pin country_code to us so you get US pricing, and pace your requests with backoff and jitter. Reading the structured data rather than the visible buy box also makes your scraper survive Newegg's frequent layout changes.
Yes. The rating summary (numeric value and review count) is in the JSON-LD aggregateRating, so you get it from the same fetch as the price. Individual review text lives in a lazy-loaded, paginated reviews tab that you must render and wait for, then walk page by page. Note that reviewer usernames are personal data, so handle them accordingly.
Scraping public, factual data such as prices and specs generally carries less legal risk than accessing pages behind a login, and US courts have treated public-data scraping more favorably in cases like hiQ v. LinkedIn. Still, Newegg's terms restrict automated access, review text and images are copyrighted, and reviewer data is personal data. Stay on public pages, keep rates polite, and consult a lawyer before scraping at commercial scale.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

XPath and CSS Selectors: Scrapers That Don't Break
Scrape data with XPath and CSS selectors that survive redesigns: label-anchored XPath, stable attributes, namespace fixes, and a selector-drift detector.

Stealth Plugins for Puppeteer and Playwright: What Works
Stealth plugins patch the known automation tells in Puppeteer and Playwright. Which ones still matter, why the patch set is a signature, and how to test yours.

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.
