How to Scrape AliExpress Product Data
Scrape AliExpress product data: pull prices, orders, ratings, and SKU variations from window.runParams and get past the slider CAPTCHA with a scraping API.

To scrape AliExpress at any real scale, you fight a different battle than you do on Amazon or eBay. AliExpress doesn't scatter its data across fragile CSS selectors. It ships almost the entire product, price, SKU, and seller payload as one JSON blob in the page source, inside a global called window.runParams. Parse that and you skip most of the DOM archaeology. The catch is getting the page at all: AliExpress sits behind Alibaba's own anti-bot stack, complete with a drag-to-solve slider CAPTCHA that returns HTTP 200 while handing you zero product data. This guide walks the full pipeline for public product data: which fields live in runParams, how to catch a slider block, how currency and shipping shift by country, and how to paginate search at volume. Every code sample uses SparkProxy's Scraping API, so the anti-bot layer is one request parameter instead of an infrastructure project you babysit.
Is scraping AliExpress legal?
Public data scraping sits in a narrower lane than most people assume, so get the framing right before you write a line of code.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that's publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That decision is about unauthorized access, not a blanket license. AliExpress's Terms of Use separately prohibit automated collection and reverse engineering, so scraping can still be a breach of contract even where it isn't a CFAA violation. Those are two different legal questions, and "public" only answers the first.
Practical guardrails that keep a project defensible:
- Collect public product data only: title, price, orders, rating, shipping, seller name. Never anything behind a login, and never personal data about buyers or reviewers.
- Rate-limit yourself and back off on errors so you aren't degrading the service.
- Don't republish copyrighted assets (product photos, full review text) beyond what fair use allows.
- If the data feeds a commercial product, run it past a lawyer. This guide is engineering guidance, not legal advice.
Price monitoring, catalog research, and competitive intelligence are common, legitimate uses of public AliExpress data. For the business context around that, see How E-commerce Companies Use Proxies for Competitive Intelligence.
What product data you can extract (fields reference)
A public AliExpress product page (reachable at /item/) serializes almost everything you'd want into the window.runParams.data object. The DOM around it changes, but the JSON keys are far more stable than any CSS selector. Here's the reference set worth pulling, with the JSON paths that work as of mid-2026:
| Field | Path in `runParams.data` | Example value | Notes |
|---|---|---|---|
| Product ID | `actionModule.productId` | `1005006789012345` | Numeric; the product's unique key |
| Title | `titleModule.subject` | "Wireless Earbuds Pro" | Stable across layout variants |
| Price (display) | `priceModule.formatedActivityPrice` | "US $12.99 - 18.99" | Falls back to `formatedPrice` |
| Price (numeric) | `priceModule.minAmount.value` / `maxAmount.value` | `12.99` / `18.99` | A range when the item has SKUs |
| Currency | `currencyModule.currencyCode` | "USD" | Driven by the region/currency cookie |
| Orders sold | `titleModule.formatTradeCount` | "3,000+ sold" | Raw int in `tradeCount` |
| Rating | `titleModule.feedbackRating.averageStar` | "4.7" | Out of 5 |
| Reviews | `titleModule.feedbackRating.totalValidNum` | `1240` | Count of valid reviews |
| Seller / store | `storeModule.storeName` / `storeNum` | "ABC Store" / `1101234` | `sellerAdminSeq` is the seller id |
| Shipping | `shippingModule.generalFreightInfo.originalLayoutResultList[0].bizData.displayAmount` | "Free" or "US $2.31" | Varies by ship-to country |
| SKU options | `skuModule.productSKUPropertyList` | Color, Size lists | Human-readable variant names |
| SKU prices | `skuModule.skuPriceList` | per-combo price + stock | One row per buyable variant |
| Main images | `imageModule.imagePathList` | array of URLs | Gallery, first is the hero shot |
That single object replaces a dozen brittle selectors. The productId is your anchor: it's the numeric identifier that uniquely names a listing, so store it as your primary key and hang every other field off it.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why AliExpress is hard to scrape
AliExpress inherits Alibaba's anti-bot infrastructure, which is built to stop exactly what you're doing. Four things break naive scrapers:
The slider CAPTCHA. When Alibaba's "baxia" defense flags a request, it serves a puzzle-slider challenge ("Please slide to verify") instead of the product. The trap mirrors Amazon's Robot Check: the challenge often returns HTTP 200. If your code trusts the status code, response.ok is True, you save the "page", and you've stored a CAPTCHA shell with no runParams in it. A 200 without runParams is still a block.
The punish redirect. Repeated or suspicious traffic gets bounced through an Alibaba verification endpoint whose URLs and bodies contain markers like _____tmd_____/punish and x5secdata. That page carries no product JSON either.
IP reputation. Plain datacenter ranges get flagged fast on AliExpress, so a single static proxy dies quickly. Residential IPs blend in and last far longer, and rotating the exit IP per request is the difference between a scraper that runs for an hour and one that runs for a week.
Multiple front-ends and A/B'd shapes. AliExpress serves aliexpress.com globally and aliexpress.us for US shoppers, and it A/B tests the exact wrapper around runParams. The data object is consistent; the JavaScript that assigns it is not. Extract defensively.
| Signal | What you'll see | How to handle it |
|---|---|---|
| Slider CAPTCHA | Body with "slide to verify", `nc_1_wrapper`, no `runParams` | Rotate IP, retry with `stealth` |
| Punish redirect | URL or body with `_____tmd_____/punish`, `x5secdata` | Detect in body, use a fresh residential IP |
| Rate limit | Repeated punish pages after bursts | Lower concurrency, back off |
| Empty `runParams` | HTTP 200 but no product JSON | Treat as a soft block, retry |
A managed scraping API absorbs the IP rotation and challenge handling for you. The parsing of runParams is yours, because it lives in the HTML the API returns. For the proxy-side theory behind ban avoidance, 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 for you. You send one request; you get the rendered HTML back. For AliExpress, four parameters carry the weight:
render_js=true: AliExpress builds the product view with JavaScript, andrunParamsis injected during that build, so a raw fetch can come back thin. Rendering with a real Chromium browser gets the full source.premium_proxy=true: routes through residential IPs, which survive Alibaba's defenses where datacenter IPs get flagged.stealth=true: adds fingerprint hardening that helps against the baxia challenge.country_code: the ISO alpha-2 code of the exit country (US,GB,DE), which sets the default currency and shipping estimate you'll see on the page.
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.aliexpress.com/item/1005006789012345.html" \
--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 live in the Scraping API docs. If you're weighing this against running your own proxy pool, Web Scraping API vs Self-Managed Proxies lays out the trade-off.
Fetch a product page by ID
Start with one product. Wrap the request so every call carries the AliExpress-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_product(product_id: str, country: str = "US") -> str:
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": f"https://www.aliexpress.com/item/{product_id}.html",
"render_js": "true", # AliExpress builds the page with JS
"premium_proxy": "true", # residential IPs survive Alibaba's anti-bot
"stealth": "true", # extra fingerprint hardening for baxia
"country_code": country, # exit country -> default currency + shipping
},
timeout=120,
)
resp.raise_for_status()
return resp.text
Before you trust the HTML, check whether AliExpress handed you a slider or punish page. Because those return 200, raise_for_status() won't catch them. Scan the body, and treat a missing runParams as a soft block on its own:
def is_blocked(html: str) -> bool:
"""AliExpress serves its slider/punish page with HTTP 200, so the status lies."""
markers = (
"_____tmd_____/punish", # Alibaba baxia punish redirect
"x5secdata", # slider verification payload
"Please slide to verify",
"nc_1_wrapper", # NoCaptcha slider DOM node
"baxia",
)
if any(m in html for m in markers):
return True
return "runParams" not in html # a real product page always ships runParams
Now a single fetch is honest: it either returns a real product page or tells you it was blocked so you can retry.
Parse product data from window.runParams
Here's the move most AliExpress tutorials get wrong. They chase CSS selectors that Alibaba rotates weekly, when the entire product payload is sitting in the page as JSON. AliExpress serializes data as a JSON object assigned to window.runParams, so you extract that object once and read every field from a dictionary.
The reliable grab is the object between data: and the csrfToken key that follows it:
import re
import json
def extract_run_params(html: str) -> dict | None:
# AliExpress serializes the product payload as JSON on window.runParams.data.
# Capture the object that sits between `data:` and the trailing csrfToken key.
m = re.search(r"data:\s*(\{.*?\}),\s*csrfToken", html, re.DOTALL)
if not m:
return None
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
return None
The data object is deep and some modules are absent on some listings, so read it through a small safe-traversal helper instead of chaining [...] and catching KeyError everywhere:
def dig(node, *path, default=None):
"""Walk dict keys and list indexes; return default on any miss."""
for key in path:
if isinstance(node, dict):
node = node.get(key)
elif isinstance(node, list):
try:
node = node[key]
except (IndexError, TypeError):
return default
else:
return default
if node is None:
return default
return node
Now the core fields collapse into one function that reads from the parsed object:
def parse_product(data: dict) -> dict:
return {
"product_id": dig(data, "actionModule", "productId"),
"title": dig(data, "titleModule", "subject"),
"orders": (dig(data, "titleModule", "formatTradeCount")
or dig(data, "titleModule", "tradeCount")),
"rating": dig(data, "titleModule", "feedbackRating", "averageStar"),
"reviews": dig(data, "titleModule", "feedbackRating", "totalValidNum"),
"price": (dig(data, "priceModule", "formatedActivityPrice")
or dig(data, "priceModule", "formatedPrice")),
"price_min": dig(data, "priceModule", "minAmount", "value"),
"price_max": dig(data, "priceModule", "maxAmount", "value"),
"currency": dig(data, "currencyModule", "currencyCode"),
"store_name": dig(data, "storeModule", "storeName"),
"store_id": dig(data, "storeModule", "storeNum"),
"seller_id": dig(data, "storeModule", "sellerAdminSeq"),
"store_rating": dig(data, "storeModule", "positiveRate"),
"shipping": dig(data, "shippingModule", "generalFreightInfo",
"originalLayoutResultList", 0, "bizData", "displayAmount"),
"image": dig(data, "imageModule", "imagePathList", 0),
}
One honest caveat. AliExpress moves keys between builds, and aliexpress.us sometimes namespaces modules differently from aliexpress.com. When a field returns None, dump the parsed data object once with json.dumps(data, indent=2)[:4000] and locate the module by eye; the top-level module names (titleModule, priceModule, skuModule, storeModule, shippingModule) are the parts that rarely move.
Extract SKU variations and per-variant prices
AliExpress prices are rarely a single number. A listing usually carries variations (color, size, plug type, bundle) and each buyable combination has its own price and stock. That lives in skuModule, split across two arrays: productSKUPropertyList holds the human-readable options, and skuPriceList holds one entry per combination.
Read the options first so you know what the variant axes are:
def variation_options(data: dict) -> dict:
"""{'Color': ['Black', 'Blue'], 'Plug Type': ['US', 'EU']} and so on."""
options = {}
for prop in dig(data, "skuModule", "productSKUPropertyList", default=[]):
name = prop.get("skuPropertyName")
values = [v.get("propertyValueDisplayName")
for v in prop.get("skuPropertyValues", [])]
options[name] = values
return options
Then flatten the price list into one row per combination. Each entry's skuAttr string encodes which property values it maps to, so you can join it back to the option names above:
def sku_prices(data: dict) -> list[dict]:
rows = []
for sku in dig(data, "skuModule", "skuPriceList", default=[]):
val = sku.get("skuVal", {})
rows.append({
"sku_id": sku.get("skuId"),
"attributes": sku.get("skuAttr"), # "14:193#Black;5:100014064#US"
"price": dig(val, "skuAmount", "value"),
"sale_price": dig(val, "skuActivityAmount", "value"),
"stock": val.get("availQuantity"),
})
return rows
This is where the JSON approach pays off. Scraping per-SKU prices from the rendered DOM means clicking every swatch and re-reading the price node, which is slow and block-prone. From skuPriceList you get the full price matrix in one request, including combinations that are out of stock and hidden in the UI. For a repricing or price-comparison feed, that completeness is the whole point.
Pin country, currency, and shipping
Here's the gotcha that silently corrupts AliExpress price datasets: AliExpress localizes currency, price, and shipping by the shopper's region. The same listing shows US $12.99 to one visitor and a euro-converted price with a different shipping fee to another. If you scrape from rotating IPs in different countries without pinning region and currency, your price history jitters for reasons that have nothing to do with the seller changing anything.
There are two layers to control:
Exit country. country_code sets the country of the residential IP, which drives the default marketplace behavior and the shipping estimate (AliExpress quotes freight to the detected destination). Set it to the market you're tracking and hold it constant.
Currency and region. AliExpress stores the shopper's site, currency, region, and locale in the aep_usuc_f cookie. Inject it with the cookies parameter so every request in a batch resolves to the same currency, instead of letting AliExpress guess from geolocation:
import json
import requests
def fetch_localized(product_id: str, country: str, currency: str) -> str:
aep = f"site=glo&c_tp={currency}®ion={country}&b_locale=en_US"
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": f"https://www.aliexpress.com/item/{product_id}.html",
"render_js": "true",
"premium_proxy": "true",
"stealth": "true",
"country_code": country,
"cookies": json.dumps([{"name": "aep_usuc_f", "value": aep}]),
},
timeout=120,
)
return resp.text
Pick one canonical country plus currency per feed and keep it fixed across every run, so a price change in your data reflects a real price change and not a currency or region flip. If you're building a price-comparison product, this consistency is the entire game; we get into it in Datacenter Proxies for Price Comparison Websites.
Scrape and paginate search results
Search pages let you discover product IDs by keyword. The modern search URL is https://www.aliexpress.com/w/wholesale-, and the results are embedded as JSON in window.runParams, the same pattern as the product page. The item array lives at mods.itemList.content:
def extract_search_json(html: str) -> dict | None:
# Search pages embed a runParams object too; the wrapper differs from product pages.
m = re.search(r"window\.runParams\s*=\s*(\{.*?\});?\s*</script>", html, re.DOTALL)
if not m:
return None
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
return None
def parse_search(html: str) -> list[dict]:
data = extract_search_json(html)
rows = []
for item in dig(data, "mods", "itemList", "content", default=[]):
rows.append({
"product_id": item.get("productId"),
"title": dig(item, "title", "displayTitle"),
"price": dig(item, "prices", "salePrice", "formattedPrice"),
"orders": dig(item, "trade", "tradeDesc"),
"store": dig(item, "store", "storeName"),
})
return rows
To walk pages, request ?page=N and stop when a page returns no items:
from urllib.parse import quote
def scrape_search(keyword: str, max_pages: int = 6) -> list[dict]:
results = []
for page in range(1, max_pages + 1):
url = f"https://www.aliexpress.com/w/wholesale-{quote(keyword)}.html?page={page}"
html = fetch_url(url) # same API call shape as fetch_product
if is_blocked(html):
continue
rows = parse_search(html)
if not rows:
break
results.extend(rows)
return results
Two things to plan around. The search JSON shape drifts more than the product page does, so when itemList.content comes back empty, dump the parsed object and confirm the path before assuming it's a block. And broad queries like "earbuds" only surface a slice of the catalog, so narrow with AliExpress's own filters (category, price band, SortType) and run several targeted queries, then dedupe by product_id. Ten narrow queries reach far more listings than one broad sweep.
Scale without getting blocked
At volume, three things keep an AliExpress scraper healthy: retries on soft blocks, backoff so you don't spike a single IP, and modest concurrency. With a scraping API the provider rotates the exit IP for you, so your ceiling is your plan's rate limit rather than the number of proxies you own. Keep worker counts sane (5 to 15) and let retries absorb the occasional slider page.
import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_with_retry(product_id: str, attempts: int = 3) -> str | None:
for i in range(attempts):
html = fetch_product(product_id)
if not is_blocked(html):
return html
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return None
def scrape_catalog(ids: list[str], workers: int = 8) -> list[dict]:
out = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(fetch_with_retry, pid): pid for pid in ids}
for fut in as_completed(futures):
html = fut.result()
if html is None:
continue
data = extract_run_params(html)
if data:
out.append(parse_product(data))
return out
The jitter matters more than it looks. random.random() staggers retries so a batch of failures doesn't retry in lockstep and re-trigger the same challenge. Persist results as you go rather than holding everything in memory, so a crash at product 40,000 doesn't cost you the first 39,999. A flat CSV is enough to start:
import csv
def save_csv(rows: list[dict], path: str = "aliexpress_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)
For a running price tracker, append a scraped_at timestamp plus the country_code and currency you pinned to each row, and key your store on (product_id, scraped_at). That gives you a clean time series where every price point is comparable because the region was held constant. Run it long enough and the only thing that keeps the pipeline alive is disciplined block handling, which is the same discipline covered in How to Avoid Getting Your Proxy Blocked.
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 AliExpress's Terms of Use, which prohibit automated collection. Stick to public product data, avoid personal data about buyers or reviewers, don't overload the servers, and get legal advice before any commercial use.
The slider is served by Alibaba's baxia anti-bot when it flags a request, and it often returns HTTP 200 with no product JSON, so scan the body rather than trusting the status code. Reduce triggers with rotating residential IPs and a real browser: with the SparkProxy Scraping API set render_js=true, premium_proxy=true, and stealth=true, detect the slider and punish markers in the response, then retry with backoff.
window.runParams is a global object AliExpress injects into the product page, and its data key holds the full payload (title, price, orders, rating, seller, shipping, SKUs) as JSON. Extract the object between data: and csrfToken with a regex, run json.loads on it, then read fields from modules like titleModule, priceModule, and skuModule instead of chasing CSS selectors.
AliExpress localizes currency and price by region, so pin both. Set country_code to fix the exit country, and inject the aep_usuc_f cookie through the cookies parameter with your target currency (for example site=glo&c_tp=USD®ion=US&b_locale=en_US). Hold those constant across runs so a price change in your data reflects a real change, not a currency flip.
Yes. The skuModule object carries productSKUPropertyList (the option names like Color and Size) and skuPriceList (one entry per combination with its own price, sale price, and stock). Reading that array gives you the full price matrix from a single page load, including out-of-stock combinations the UI hides behind swatch clicks.
From a single IP, bursts draw slider and punish pages quickly, and the threshold is unpublished and moving. With a scraping API the provider rotates the exit IP per request, so your practical limit is your plan's rate rather than a per-IP cap. Keep concurrency modest (5 to 15 workers), add exponential backoff with jitter, and retry when your is_blocked check fires.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
