---
title: "How to Scrape AliExpress Data at Scale"
slug: how-to-scrape-aliexpress
date: 2024-12-24T07:28:36+00:00
modified: 2026-10-06T10:42:37+00:00
permalink: https://brightdata.com/blog/web-data/how-to-scrape-aliexpress
type: blog
---

[ Blog ](https://brightdata.com/blog "Blog") / [Web Data](https://brightdata.com/blog/web-data)







 [Web Data](https://brightdata.com/blog/web-data)

# How to Scrape AliExpress Data at Scale

Learn how to scrape AliExpress prices, stock, and variants with Python, catch blocks that return HTTP 200, and collect at scale with Bright Data.

 36 min read





 [ ](https://brightdata.com/blog/authors/satyam-tripathi)

 [Satyam Tripathi

Technical Writer

 ](https://brightdata.com/blog/authors/satyam-tripathi)





 ![How to Scrape AliExpress blog image](https://media.brightdata.com/2024/12/How-to-Scrape-AliExpress.svg)





Scraping AliExpress collects the price, stock, variants, rating, and reviews that price monitoring and competitor tracking need. Most of it isn’t in the HTML, because the page loads it afterward from a signed internal API.

In my tests, a plain request returned HTTP 200 and no price, so a pipeline would log a success with an empty row. From one IP address, the signed API started to block calls within a dozen requests.

This guide shows you how to get that data, detect a failure that looks like a success, and use Bright Data’s [AliExpress Scraper API](/products/web-scraper/aliexpress) at scale.

## TL;DR

- **The `window.runParams` object that older tutorials parse is empty.** Price, stock, and SKU data load from `mtop.aliexpress.pdp.pc.query`, which needs an MD5 signature.
- **Detect blocks by markers, not status codes.** Blocked requests returned HTTP 200, a `bxpunish` header, and a body marker such as `rgv587_flag`.
- **A correct signature didn’t prevent blocks.** Two runs of 60 calls from one IP address, with no delay, got data from 7 and 15.
- **At scale, scrape the public `/item/` product pages.** Bright Data’s AliExpress Scraper API extracts structured data, and the Browser API lets your script read each page’s full API response.

## What happens when you scrape AliExpress with an HTTP client

Start with one HTTP request, sent without a browser or a proxy. If it times out, your network can’t reach AliExpress directly, and a proxy or one of the managed methods below sends the request from a network that can.

Everything below uses 3 packages. The Scraper API script in a later section reads your Bright Data [API key](https://docs.brightdata.com/api-reference/authentication) from an environment variable:

```none
pip install curl_cffi playwright requests
export BRIGHTDATA_API_KEY="your-api-key"    # PowerShell: $env:BRIGHTDATA_API_KEY="your-api-key"
```

Fetch one product page with [`curl_cffi`](/blog/web-data/web-scraping-with-curl-cffi) and read the `<title>`, the `window.runParams` object that older tutorials parse, and the bootstrap object that the page passes to its own JavaScript. These 3 values show whether a plain HTTP client is enough:

```none
import re
from curl_cffi import requests as cffi

# any product URL that still exists works
ITEM = "https://www.aliexpress.com/item/1005001398867186.html"

# no User-Agent of your own: curl_cffi sends one that matches the browser it impersonates
r = cffi.get(ITEM, impersonate="chrome", timeout=30)
html = r.text

print("status      ", r.status_code)
print("bytes       ", len(html))
print("title       ", repr(re.search(r"<title>(.*?)</title>", html, re.S).group(1)))
print("runParams   ", repr(re.search(r"window\.runParams\s*=\s*(\{.*?\});", html, re.S).group(1)))
print("isCSR       ", re.search(r"isCSR\s*=\s*(\w+)", html).group(1))
print("crawler flag", re.search(r'"crawler":(\w+)', html).group(1))
```

The fetch succeeds:

```none
status       200
bytes        77226
title        ''
runParams    '{\n                    }'
isCSR        true
crawler flag false
```

The page is an HTML shell, with no price, stock, SKU, rating, or reviews. Before any JavaScript runs, the shell already has the Open Graph tags. `og:title` contains the real product name while `<title>` is blank, and `og:image`, `og:url`, and `og:type` (set to `product`) are there too. `og:description` contains only a generic AliExpress slogan, and the page has no JSON-LD, so neither source gives you product data.

A browser that loads the same URL in the same hour renders the price, rating, and variants:

*Rendered through Bright Data’s Browser API with [geolocation targeting](https://docs.brightdata.com/products/scraping-browser/features/proxy-location) set to the US. AliExpress’s internal API returns the same `$14.40` list price and 4.7 rating for this product. The sale price comes from a temporary promotion and changes over time.*

Searching the page source for that list price finds nothing:

*Captured on the US storefront, which serves the same 77 KB shell.*

**The catalog fields are cheap to collect.** A single GET request with no unblocking returns the catalog fields, so you can map product IDs to titles and main images without rendering. Price and assortment pipelines need price, stock, and variant data, which comes from AliExpress’s internal API gateway. Plan those 2 jobs separately.

**Let curl\_cffi choose the User-Agent.** `impersonate="chrome"` is an alias rather than a version. In the library’s source, curl\_cffi 0.16.1 maps the alias to `chrome150` through a `DEFAULT_CHROME` constant, and that constant changes whenever a new profile is released. If you don’t set the header, the library also sends the matching `Chrome/150` User-Agent.

If you pass your own User-Agent, it replaces the library’s User-Agent, so a hardcoded `Chrome/141` string is sent with a Chrome 150 TLS handshake. The mismatch raises no error, so you can deploy it without noticing. Either omit the header and let the library pair the User-Agent with the handshake, or pin the profile to a version and match the string to it. Engineers who build TLS clients report that a mismatched identity is easier to detect than a consistent one, and that the User-Agent becomes outdated long before the TLS handshake does.

**The `isCSR` flag shows how the page renders.** Its value is `true` because AliExpress moved the product detail page to client-side rendering (CSR). `window.runParams` is an unused empty object that regex-based extractors still match. A parser that reads it raises no error and returns nothing.

**Every client I tried got HTTP 200 and the same 77 KB shell.** I sent 20 consecutive requests to that URL from one residential IP address without a proxy, and all of them returned HTTP 200. The requests ran in a single session at one request every 0.4 seconds, with no User-Agent rotation. I also fetched the same URL as `python-requests/2.32.5`, as `curl/8.7.1`, and as 3 [TLS impersonation profiles](/blog/web-data/web-scraping-with-curl-impersonate).

Sending the Googlebot User-Agent switches `"crawler"` from `false` to `true` in the bootstrap object. The response differs by 1 byte. The flag didn’t add a server-rendered payload, so spoofing Googlebot changes one boolean value and nothing else.

On a client-rendered marketplace, check the data before you log a fetch as a success. The status code only shows that the HTTP request succeeded. On this target, the request succeeded even when the data was missing.

## The block that returns HTTP 200

AliExpress does block requests, but the HTTP status line doesn’t show the block.

The site’s own [robots.txt](https://www.aliexpress.com/robots.txt) names an internal endpoint in a Googlebot-specific rule, and that endpoint may return the product data. Call it directly and read the response headers before the body:

```none
from curl_cffi import requests as cffi

PID = "1005001398867186"
url = f"https://www.aliexpress.com/aeglodetailweb/api/msite/item?productId={PID}"
r = cffi.get(url, impersonate="chrome", timeout=30,
             headers={"Accept": "application/json",
                      "Referer": f"https://www.aliexpress.com/item/{PID}.html"})

print("status", r.status_code, "bytes", len(r.text))
for k in ("server", "x-rule-name", "bxpunish", "set-cookie"):
    print(f"  {k:12s} {str(r.headers.get(k))[:70]}")
print("body marker:", "rgv587_flag" in r.text, "| x5secdata:", "x5secdata" in r.text)
```

The response comes from Alibaba’s Baxia anti-bot system, and it has a success status code:

```none
status 200 bytes 2399
  server       Tengine/AServer-Ingress
  x-rule-name  AE_GLOBAL_UNLOGIN
  bxpunish     1
  set-cookie   x5secdata=xffEdBbOh5ArPGTjyI_40B3_06C7jiK2nBFPGlpVzkk2x66w14DH6V
body marker: True | x5secdata: True
```

The body is a script that redirects to a `_____tmd_____/punish` URL through `window.location` and sets `window._config_ = {"action":"captcha", ...}`. It ends with `\<!--rgv587_flag:sm-->`, Alibaba’s block marker. The `x-rule-name` header names the rule that matched, `AE_GLOBAL_UNLOGIN`, which appears to be the rule for unauthenticated global traffic. The same header also came back on normal, unblocked responses, so it doesn’t mark a block on its own.

**Status-code checks accept this blocked response.** `raise_for_status()` doesn’t raise, and a `status_code == 200` check returns true. If a dashboard counts HTTP 2xx responses as successes, it reports a healthy pipeline while the rows are empty.

So detect blocks by the response headers and body, not by the status code. Alibaba’s error strings can change without notice, but a dedicated response header and a marker string in the body are cheap to check and cheap to extend:

```none
BLOCK_MARKERS = ("rgv587_flag", "x5secdata", "_____tmd_____", "FAIL_SYS_USER_VALIDATE")

def is_blocked(resp, min_bytes=1024):
    """True when Baxia responded instead of the application."""
    if resp.headers.get("bxpunish"):
        return True
    body = resp.text
    if len(body) < min_bytes:          # treat a near-empty 200 as a block too
        return True
    return any(m in body[:4096] for m in BLOCK_MARKERS)
```

In my runs, those checks detected every block that the status code didn’t show. Run the same checks before your parser, and add them before you write any selectors. Without them, you learn about a block on this target only when your parser finds no data.

Log the reason for each failed request. On this target, a block page, an empty shell, and a price in the wrong currency each need a different fix, and one overall success rate hides all 3. Split the reasons by exit country too, because a wrong-currency price still looks like a success.

## Signing product API calls yourself

The product fields do exist as JSON. If you load one page in a browser and record its XHR traffic, you can see that those fields come from an API gateway the page calls.

A page load makes calls to Alibaba’s MTOP gateway at `acs.aliexpress.com/h5/<api>/<version>/`, each with `jsv`, `appKey`, a millisecond timestamp `t`, and a 32-character `sign`. `mtop.aliexpress.pdp.pc.query` returns price, stock, SKU, store, and rating. Review text comes from a second signed call, `mtop.aliexpress.review.pc.list`, on the same gateway, so collecting reviews needs a second request per product. Reading the inline bootstrap script shows that the page can call more than one product API:

Page variant (`viewName`)Prefetched API`newDetail``mtop.aliexpress.pdp.pc.query``choiceDetail``mtop.aliexpress.itemdetail.pc.asyncPCDetail``h5Detail``mtop.aliexpress.itemdetail.msite`All 6 product pages I sampled had `viewName = 'newDetail'`. The other 2 APIs, the mobile `mtop.aliexpress.itemdetail.msite` and `mtop.aliexpress.itemdetail.pc.asyncPCDetail`, weren’t easier to access. Both returned `FAIL_SYS_USER_VALIDATE` with `bxpunish: 1` on the first request, sent with the same payload the desktop API accepts.

Read `viewName` on each fetch to see when a listing starts rendering as a different page variant. A scraper hardcoded to one API name would miss that change. The page’s bootstrap script sets a 15,000 ms prefetch timeout and aborts its own XHR at 10,000 ms, so use 10,000 ms as the upper bound for your own timeouts.

In the browser’s Network panel, filtered to `pdp.pc.query`, the page’s own request contains every value your signing code needs:

*The page’s own call, captured on the US storefront, where this listing’s ID is `3256801212552434`. The page sends `originaljsonp` and adds a `timeout` and an `ext` block. The signing code below sends `jsonp` and omits both, and the gateway accepted that shorter request. One token value is redacted.*

The `sign` value uses the Alibaba H5 signing scheme, which is short enough to reimplement in one function. The scheme joins the session token, the timestamp, the app key, and the exact request body with ampersands, and then hashes the result with MD5:

```none
import hashlib, json, time
from curl_cffi import requests as cffi

APP_KEY, API, VER = "12574478", "mtop.aliexpress.pdp.pc.query", "1.0"
GATEWAY = f"https://acs.aliexpress.com/h5/{API}/{VER}/"

def sign(token, t, data):
    return hashlib.md5(f"{token}&{t}&{APP_KEY}&{data}".encode()).hexdigest()

def pdp(session, product_id, token=""):
    data = json.dumps({"productId": product_id, "_lang": "en_US", "_currency": "USD",
                       "country": "US", "province": "", "city": "", "channel": "",
                       "pdp_ext_f": "{}", "sourceType": "", "clientType": "pc"},
                      separators=(",", ":"))
    t = str(int(time.time() * 1000))
    params = {"jsv": "2.5.1", "appKey": APP_KEY, "t": t, "sign": sign(token, t, data),
              "api": API, "v": VER, "type": "jsonp", "dataType": "jsonp",
              "callback": "mtopjsonp1", "data": data}
    return session.get(GATEWAY, params=params, impersonate="chrome", timeout=30,
                       headers={"Referer": "https://www.aliexpress.com/"})

s = cffi.Session()
first = pdp(s, "1005001398867186")                       # unsigned, returns the token cookie
token = s.cookies.get("_m_h5_tk", "").split("_")[0]
second = pdp(s, "1005001398867186", token)               # signed with that token
print("call 1:", len(first.text), "bytes |", first.text[:60])
print("call 2:", len(second.text), "bytes")
```

The first call returns the token, and the second call returns the product:

```none
call 1: 151 bytes | mtopjsonp1({"api":"mtop.aliexpress.pdp.pc.query","data":{},"ret
call 2: 103748 bytes
```

Call 1 returns `FAIL_SYS_TOKEN_EMPTY` and a `Set-Cookie` header with `_m_h5_tk`. Call 2 is signed with the first part of that cookie’s value and returns JSONP, a function call wrapped around one JSON object. If you remove the `mtopjsonp1(...)` wrapper, you get the full product record, including the title, `originalPrice` `$14.40`, `salePriceString` `$6.84`, 59,924 units of inventory, rating 4.7, seller ID 200422816, and `categoryPath` `13/5/141914`.

**You need only a small part of that payload.** The response has 27 top-level modules, and 8 of them contain the fields a price or assortment pipeline would store. Serialized as compact JSON, those 8 modules are 16,494 bytes, which is 15.9% of the whole result object. The largest module is `SHIPPING` at 34,071 bytes, and the second largest is `GLOBAL_DATA` at 23,783 bytes.

Signing needs one MD5 hash, one cookie, and one retry. If you’ve reimplemented the H5 signing scheme for another Alibaba site, expect a few hours of work. So how long does your signing code keep working?

## How many signed API calls succeed from one IP address

Running the signed call in a loop answers that question quickly. The test uses 10 product IDs, one token, one residential IP address, and no delay between calls. The script counts each `ret` code the gateway returns:

```none
# continues the session from the signing code: pdp(), s and token are defined there
from collections import Counter
import re

# ten product IDs, replace any that were removed
IDS = ["3256812884305776", "1005007712136541", "1005002754717958", "1005002495501001",
       "2255799968025159", "1005008121783683", "3256809736486496", "1005008175462016",
       "1005012238762416", "3256812840307416"]

outcomes, first_fail = Counter(), None
for i in range(1, 61):
    r = pdp(s, IDS[i % len(IDS)], token)
    ret = re.search(r'"ret":\["([^"]+)"', r.text).group(1).split("::")[0]
    key = ret + (" +bxpunish" if r.headers.get("bxpunish") else "")
    outcomes[key] += 1
    if key != "SUCCESS" and first_fail is None:
        first_fail = i
print(dict(outcomes), "| first non-SUCCESS at", first_fail)
```

The loop stopped getting data after the 7th call:

```none
{'SUCCESS': 7, 'FAIL_SYS_USER_VALIDATE +bxpunish': 53} | first non-SUCCESS at 8
```

Each blocked response’s `ret` array also contained `RGV587_ERROR::SM`.

**The block repeated, but the success count changed.** A second run from the same IP address sent 6.2 requests per second, recorded the status code and response length of each call, got 15 successes in 60, and was first blocked at request 12. It returned HTTP 200 for all 60 calls, and all 45 of its blocked responses were exactly 543 bytes. So base your capacity plan on the block, not on either success count.

**A new session didn’t remove the block.** Each of 3 new sessions in a row started from an empty cookie jar and was blocked before the client could get a token. A request sent after a 60-second wait, again from an empty cookie jar, was blocked as well. So an automatic retry adds traffic without adding data, and proxy engineers warn that retry logic like this spends budget without any visible error.

This test used no delay between calls. Next, test the variables a production scraper adds, such as a long backoff, a new token for each batch, a deliberately slow request rate, and the `ext` block that the page’s own request signs. That block contains a `crawler` flag and an `x-m-biz-bx-region` key.

The signature stayed correct from request 7 to request 60, so more reverse engineering is unlikely to increase the success counts of 7 and 15 in 60 requests. The tests exclude the session as the cause. No test changed the IP address and the fingerprint separately, so the block could depend on either one. Anti-bot systems can also link one fingerprint across many IP addresses, so practitioners change the IP address, fingerprint, and cookies together.

**Three checks show that the client isn’t the cause.** On the `tls.peet.ws` fingerprint echo, the signing code on curl\_cffi 0.16.1 matched a real Chromium 152 on that machine, with the same [JA4 fingerprint](/blog/web-data/tls-fingerprinting), `t13d1516h2_8daaf6152771_806a8c22fdea`, and the same HTTP/2 fingerprint. Five fingerprints from curl\_cffi and the Rust libraries `rnet` and `wreq` produced 100 blocked responses and no token, even though `wreq` ships newer Chrome profiles than curl\_cffi 0.16.1. Patchright, nodriver, and Camoufox each succeeded 0 times in 8 attempts from that IP address, while Bright Data’s Browser API, on a different exit IP, returned the product in the same minute.

That IP address was already blocked when those 3 tools ran, so all 3 failed the same way on it. The zeros show the IP address’s reputation, and they don’t rank the tools.

The first check, TrackMe’s fingerprint echo at `tls.peet.ws`, returns every fingerprint from a single request:

*Check that a profile reproduces both the JA4 and the HTTP/2 fingerprint of the browser it’s named after.*

**A newer profile wasn’t a better match here.** The `wreq` Chrome 152 profile sent a ClientHello with 17 extensions, and Chrome 152 itself sent 16. The version number in a profile’s name doesn’t guarantee the fingerprint it sends, so check each profile against a fingerprint echo before you trust it. Here, curl\_cffi’s default profile matched the real browser exactly.

Reverse engineering a signed endpoint tells you what the data looks like, but not how much of it you can collect. In these tests, neither a better signature nor a better browser increased that amount.

## Letting the page sign product API calls for you

A second way to get the product payload uses browser automation instead of API calls from your own code. The page already makes the signed call. Attach a response handler and read the responses the page receives. Through the Browser API, the page’s requests also go through Bright Data’s proxy network.

Reading the page’s own response removes the token, the timestamp, the MD5 signature, and the cookie jar from your code:

```none
import json, re
from playwright.sync_api import sync_playwright

WSS = "wss://brd-customer-<id>-zone-<zone>:<password>@brd.superproxy.io:9222"

def capture_pdp(product_id):
    """Read the response to the call the page already makes."""
    bodies = []

    def keep(response):
        if "mtop.aliexpress.pdp.pc.query" not in response.url:
            return
        try:
            bodies.append(response.text())
        except Exception:
            pass                      # redirects and closed pages have no readable body

    with sync_playwright() as pw:
        browser = pw.chromium.connect_over_cdp(WSS, timeout=150_000)
        ctx = browser.contexts[0] if browser.contexts else browser.new_context()
        page = ctx.new_page()
        page.on("response", keep)
        try:
            page.goto(f"https://www.aliexpress.com/item/{product_id}.html",
                      wait_until="domcontentloaded", timeout=150_000)
            page.wait_for_timeout(13_000)
        except Exception:
            pass                      # keep whatever keep() collected before navigation ended
        browser.close()

    for body in sorted((b for b in bodies if isinstance(b, str)), key=len, reverse=True):
        m = re.match(r"\s*mtopjsonp\d*\((.*)\)\s*$", body, re.S)
        try:
            result = json.loads(m.group(1) if m else body).get("data", {}).get("result")
        except (ValueError, AttributeError):
            continue                  # skip a body that is not JSON, such as an HTML page
        if isinstance(result, dict) and len(result) > 10:
            return result
    return None

result = capture_pdp("1005001398867186")
if result:
    print(len(result["PRICE"]["skuIdStrPriceInfoMap"]), "SKUs priced")
```

`WSS` connects to a [Browser API zone](https://docs.brightdata.com/products/scraping-browser/quickstart), and its 3 placeholders come from that zone’s Access details:

*The Puppeteer / Playwright line is the value `WSS` expects, and your account ID, zone name, and password fill its 3 placeholders. The account ID is redacted here, and the Control Panel masks the password.*

**Keep every response, not just the first.** The page calls that endpoint more than once per load. Log the size of every response body that `keep()` collects to see which one contains the product:

```none
3256812884305776  sizes=[151, 170539]          141 SKUs priced
1005007712136541  sizes=[151, 244023]           75 SKUs priced
1005002754717958  sizes=[151, 105852, 106046]   20 SKUs priced
1005002495501001  sizes=[151,  96065]           18 SKUs priced
2255799968025159  sizes=[151,  93982]           15 SKUs priced
1005008121783683  sizes=[151,  63780]            6 SKUs priced
```

The 151-byte response sets the token cookie. The large response body after it contains the product record, including the SKU price map. When a load has more than one large response, `capture_pdp` returns the largest.

## What the AliExpress robots.txt files allow

Read the robots.txt files before you choose a method. If a directive is new to you, our [guide to robots.txt for web scraping](/blog/how-tos/robots-txt-for-web-scraping-guide) explains it. The files on the API host and the main host have different rules:

```none
$ curl -s https://acs.aliexpress.com/robots.txt
User-agent: *
Disallow: /

$ curl -s https://www.aliexpress.com/robots.txt | grep -E '/item'
Disallow: */aeglodetailweb/api/msite/item?productId*
Disallow: /items/*
Disallow: /item-img/*
Disallow: /item-img/*
```

The MTOP gateway is completely disallowed. On the main host, every rule mentioning `item` applies to something else (`/items/*`, `/item-img/*`, and the Googlebot rule on the `aeglodetailweb` endpoint). No rule applies to `/item/1005001398867186.html`, while all of `/search/*` is disallowed. Use Bright Data’s [residential proxies](/proxy-types/residential-proxies), or one of the methods below, for the paths that robots.txt allows.

Also check the main host’s sitemaps and agent manifest files. The `.com` robots.txt listed item sitemaps, and the first sitemap file contained 31,999 `/item/` URLs, so the sitemaps are a direct source of product IDs. In testing, the 6 agent manifest paths (`/llms.txt` and `/ai.txt`, plus `agent.json`, `ai-plugin.json`, `mcp.json`, and `acp.json` under `/.well-known/`) redirected with a 302 to `/p/error/404.html`.

`www.aliexpress.us/robots.txt` had the same rules as the `.com` file, without the sitemap lines. Because the site redirects visitors between the 2 hosts by region, those rules apply on both hosts. `m.aliexpress.com` publishes a longer file with its own AdsBot rule, so check the host you’re calling.

One AliExpress host publishes an exception, the only signed API path that any of these files allows. `recom-acs.aliexpress.com` serves `Disallow: /` followed by `Allow: /h5/mtop.relationrecommend.aliexpressseorecommend.recommend/*`. Called with a deliberately incomplete payload, that endpoint returned `FAIL_SYS_BIZPARAM_MISSED` without a `bxpunish` header, while the disallowed endpoints returned `FAIL_SYS_USER_VALIDATE` with that header. So robots.txt and the anti-bot system both allowed the same endpoint.

The product page is the opposite of the gateway. AliExpress serves it to the public, robots.txt doesn’t disallow it, and each of the 3 Bright Data products accessed it in testing. A Web Unlocker render, the gateway response, and a Scraper API record all had the same `$6.84` sale price in US-locale tests.

**Scrape the pages the site makes public, and let your infrastructure handle IP addresses and fingerprints.** That approach is less likely to break after a redesign or a signature change, while a signing routine copied from the site’s JavaScript bundle has to be redone after either.

## 3 methods to scrape AliExpress at scale

Bright Data’s Browser API, Web Unlocker, and Scraper API each provide the product data in a different form. Our [comparison of Web Unlocker and the Browser API](/blog/web-data/web-unlocker-vs-scraping-browser) explains how they differ in general. Searching the Control Panel’s Scrapers Library for AliExpress shows the scrapers available for this domain:

*One search returns the domain’s scrapers and a ready-to-use AliExpress dataset.*

**[Browser API](/products/scraping-browser) runs your script in a managed cloud browser with built-in unblocking and lets your script read the network responses the page receives.** When I [connected Playwright over CDP](/products/scraping-browser/playwright) and loaded the product page, the page showed the title and a buy box with its price, and my script could read all 171 responses from that single load. The Browser API also supports geolocation targeting, so the buy box shows prices in the currency of the exit country you choose. For US prices, append `-country-us` to the zone username.

[Browser API pricing](/pricing/scraping-browser) is per GB of traffic, so page weight lets you estimate a run’s cost before you start. Across 3 loads, the transferred bytes in CDP’s `Network.loadingFinished` events averaged 3.03 MB per product page, with a range of 2.68 MB to 3.47 MB. Playwright’s `networkidle` waits for 500 ms with no requests, but this page kept sending requests in the background. So `capture_pdp` waits for `domcontentloaded` and then for a fixed time.

**[Web Unlocker](/products/web-unlocker) handles the unblocking, renders the page, and returns one document.** Its raw output for the product page was 344,355 bytes, compared with the 77,226-byte shell, so the JavaScript ran. Its markdown output for this page was 21 KB to 24 KB of text that an LLM or a regex can read directly. The `brightdata scrape` command in the [Bright Data CLI](https://docs.brightdata.com/products/cli/overview) uses markdown output by default.

[Web Unlocker pricing](/pricing/web-unlocker) is per request, and you pay only for successful ones. Set `country` on each request to select the storefront.

**AliExpress Scraper API returns structured data.** You send product URLs and receive records with the fields already extracted, such as the title, list and sale price, availability, specifications, seller, rating, reviews, and image URLs. The [AliExpress Image Scraper API](/products/web-scraper/aliexpress/image) specializes in image data. [Scraper API pricing](/pricing/web-scraper) is per delivered record, so you pay only for results.

ProductPricing unitWhat you receiveUse it whenBrowser APIGB of trafficA browser session and the page’s network responsesYou want the page’s own API responses, such as each variant’s priceWeb UnlockerSuccessful requestRendered HTML or markdown you parseYou already maintain a parser, or you send pages to an LLMScraper APIDelivered recordStructured product recordsYou want records, not pages[Running your own browser](/blog/brightdata-in-practice/scraping-browser-vs-headless-browsers) is the fourth option, so I measured it too. The 3 stealth browsers (Patchright, nodriver, and Camoufox) are open source and averaged 14.1 to 15.9 seconds per load. That time is a minimum, because none of the 24 loads reached the product data, and a successful load would also spend time parsing it.

On an already-blocked IP address, all 3 stealth browsers returned the page’s header and footer but no product data. Each Bright Data product has built-in proxies, and a browser you run yourself can use Bright Data’s [AliExpress Proxies](/solutions/aliexpress-proxy).

## Running the Scraper API at scale

The Scraper API accepts URLs on one endpoint and returns records on another. Its Control Panel page shows both scraper modes beside a ready-to-run request:

*The left panel lists the domain’s 2 endpoints, Collect by URL and Discover by category URL. The `dataset_id` in the code example on the right is the same value the script below uses.*

[Synchronous mode](https://docs.brightdata.com/api-reference/scrapers/synchronous-requests) returns a `snapshot_id` when a collection takes a long time, and the collection continues in the background. For a full catalog, trigger [an asynchronous collection](https://docs.brightdata.com/api-reference/rest-api/scraper/asynchronous-requests) in chunks and save progress after each one, so that if your process crashes, the run resumes and you don’t pay for the same URLs twice:

```none
import json, os, time, requests

API = "https://api.brightdata.com/datasets/v3"
DATASET = "gd_mlj9v75u1w1jvaxvwp"          # AliExpress products
TOKEN = os.environ["BRIGHTDATA_API_KEY"]   # API key from your account settings
CHUNK = 40                                 # larger triggers ran faster per input
H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

def _await(snapshot, timeout=3600, poll=20):
    deadline = time.time() + timeout
    while time.time() < deadline:
        p = requests.get(f"{API}/progress/{snapshot}", headers=H, timeout=60).json()
        if p.get("status") in ("ready", "failed"):
            return p
        time.sleep(poll)
    raise TimeoutError(snapshot)

def _save(state, state_path):
    with open(state_path, "w") as fh:
        json.dump(state, fh)

def run(urls, state_path="run_state.json", out_path="rows.jsonl"):
    state = {"done": {}, "dead": []}
    if os.path.exists(state_path):
        with open(state_path) as fh:
            state = json.load(fh)

    for n in range(0, len(urls), CHUNK):
        key = str(n // CHUNK)
        done = state["done"].get(key, {})
        if done.get("records") is not None:     # finished means downloaded, not triggered
            continue
        batch, t0 = urls[n:n + CHUNK], time.time()
        snapshot = done.get("snapshot")         # triggered before a crash: reuse that snapshot
        if not snapshot:
            r = requests.post(f"{API}/trigger?dataset_id={DATASET}&include_errors=true",
                              headers=H, json={"input": [{"url": u} for u in batch]},
                              timeout=120)
            r.raise_for_status()
            snapshot = r.json()["snapshot_id"]
            state["done"][key] = {"snapshot": snapshot}
            _save(state, state_path)            # record the trigger BEFORE the wait

        print(f"chunk {key}: collecting {len(batch)} URLs")
        progress = _await(snapshot)
        rows = requests.get(f"{API}/snapshot/{snapshot}?format=json",
                            headers=H, timeout=600).json()
        with open(out_path, "a") as fh:
            for row in rows:
                if row.get("error_code") == "dead_page":   # listing removed on AliExpress
                    state["dead"].append(row["input"]["url"])
                elif not row.get("error"):
                    fh.write(json.dumps(row) + "\n")
        state["done"][key].update(records=progress.get("records"), errors=progress.get("errors"))
        _save(state, state_path)
        print(f"chunk {key}: collected at {(time.time() - t0) / len(batch):.1f}s per input")

    return state

run(["https://www.aliexpress.com/item/1005001398867186.html"])   # pass your full URL list
```

`include_errors=true` adds the errors report to the results, so every URL in your list has a status.

Each line in `rows.jsonl` is one product record. This script prints a few fields from the first record:

```none
import json

with open("rows.jsonl") as fh:
    row = json.loads(fh.readline())
for key in ("item_id", "title", "brand", "price", "sale_price",
            "availability", "star_rating", "review_count"):
    print(f"{key:13s}", row[key])
print("variants     ", [o["option_name"] for o in row["variants"][0]["variant_options"]])
```

The output shows the circuit breaker from the first section:

```none
item_id       1005001398867186
title         TOMZN Main Switch Residual Current Circuit Breaker With Surge Protector RCBO MCB With Lightning Protection SPD
brand         TOMZN
price         $14.40
sale_price    $8.40
availability  in_stock
star_rating   4.7
review_count  80
variants      ['16A', '20A', '25A', '32A', '40A', '50A', '63A']
```

The list price, the 4.7 rating from 80 reviews, and the 7 rated-current variants match the rendered page in the first section. The sale price comes from a temporary promotion, so it differs between captures.

**Keep every chunk full.** In these runs, a 40-URL trigger finished 2.8 times faster per URL than a 20-URL one. Fill the last chunk with URLs from your next run so it runs at full size. For hundreds of thousands of URLs, let Bright Data [deliver each snapshot](https://docs.brightdata.com/products/scrapers/scrapers-library/data-delivery) to your cloud storage or to a webhook, so results arrive without polling.

Running `run()` again with the same state file finished in 0.08 seconds, sent no triggers, and wrote no duplicate rows. The script also handles a chunk that was triggered before the process crashed during the wait. It skips a chunk only if its records were already downloaded. Otherwise, it reuses the saved snapshot, so a crash during the wait doesn’t lose a collected snapshot.

**Measure how often prices change, then match your collection schedule to that rate.** In one check, I captured 12 products twice, 25 minutes apart, and none of their prices changed. That sample can’t show a price-change rate, so use the method, not the result. Two snapshots of your own products, taken at the interval you’re considering, will answer the question for your catalog.

Discovery, the scraper’s discover-by-category endpoint, lists products from a seed URL. With the wholesale search page `/w/wholesale-ergonomic-chair.html` as the seed, a `type=discover_new&discover_by=category_url` request returned product URLs on the first run. Use `/w/wholesale-<keyword>.html` as your seed URL pattern, limit every discovery run with `limit_per_input` while you calibrate, and pass the returned URLs directly to the collection run.

If you collect product IDs from other places, such as the item sitemaps or a product page’s SEO links, a single GET request without rendering shows whether each ID still has a product page. Product pages returned 77.2 to 77.4 KB with a populated `og:title`, while 3 IDs collected from those SEO links returned 75.26 KB with every `og:` field empty. That 2 KB difference is visible in the raw response, so drop the IDs with empty `og:` fields before you queue a collection.

## 2 checks that keep price data comparable

The data needs the same validation as the HTTP responses. Each of these 2 checks is cheap to add and helps you catch prices that aren’t comparable before they reach a dashboard.

**The discount varied with the exit country, not just the currency.** I fetched one product ID through Web Unlocker in markdown format from 6 exit countries, and each country returned a different promotion:

Exit countryCurrencySale priceList priceDiscountUnited StatesUSD$6.84$14.4052.5%United KingdomGBP￡9.83￡11.1611.9%GermanyEUR11,69€13,29€12.0%BrazilBRLR$62,25R$68,308.9%AustraliaAUDAU$18.59AU$19.424.3%CanadaCADC$19.86C$20.704.1%Even the percentages aren’t directly comparable, and each page shows why. The US buy box labels its price `Ends :`, so the US row is a limited-time deal captured at one moment. The UK page says `Price includes VAT` while the US page says `Tax excluded`. The German page states `30-day lowest price before the discount`, the EU reference-price rule that tends to make German discounts look smaller.

Because of those 3 differences, set one exit country for each price series, store it in the row, and compare a series only with itself. Web Unlocker sets it with the `country` parameter, and the Browser API sets it with a `-country-` suffix on the zone username. If you need the promotions themselves rather than a price pipeline, the [AliExpress Promotions Tracker](/products/insights/promotions-tracker/aliexpress) monitors competitor promotions for you.

**The UK pound sign is the fullwidth one.** The UK storefront uses `￡`, U+FFE1 FULLWIDTH POUND SIGN, so a currency regex written with `£` (U+00A3) matches nothing. The German and Brazilian storefronts use a comma as the decimal separator in the price field. Parse the currency from the storefront setting you requested and keep the number parsing separate from the symbol.

## Every variant’s price and the full category path

Bright Data’s Browser API captures the SKU price map from an AliExpress product page, and Web Unlocker returns the category breadcrumb from its footer. Reviews also have a dedicated scraper, the [AliExpress Reviews Scraper](/products/web-scraper/aliexpress/reviews), whose records include the product ID and URL.

**The page’s own response contains the SKU price map.** The captures logged with `capture_pdp` included prices for 141, 75, 20, 18, 15, and 6 SKUs. For one product, the price map had 6 distinct prices, from `2.56` to `10.96`. The map is in the `PRICE` module, next to the preselected SKU and the `region` field:

*This capture comes from a multipack listing. The page preselects a 1-pack at $1.09, and the fourth entry is a 10-pack listed at $57.34, so the map contains each variant’s own price.*

To get each variant’s price, run `capture_pdp` through the Browser API, read `PRICE.skuIdStrPriceInfoMap`, and store it with `item_id` as the key. Read the map from the region whose prices you need, because it can vary by region too. For the circuit breaker from the first section, the map had different variant prices in a US session and a single price for all 7 variants in another region.

**Web Unlocker markdown contains the category path.** The rendered page lists the product’s categories as a breadcrumb in the footer, and the gateway’s `GLOBAL_DATA.globalData` has the same path in its `categoryPath` field, `13/5/141914`. The parser below reads the product page as Web Unlocker markdown, the format `brightdata scrape` returns by default:

```none
import re

CRUMB   = re.compile(r"^This product belongs to\s.*$", re.M)
CAT_URL = re.compile(r"/category/(\d+)/([a-z0-9-]+)\.html")

def categories_from_markdown(md):
    """Category IDs and URLs from the product page footer."""
    line = CRUMB.search(md)
    if not line:
        return []
    return [{"category_id": cid,
             "url": f"https://www.aliexpress.com/category/{cid}/{slug}.html"}
            for cid, slug in CAT_URL.findall(line.group(0))]

with open("page.md", encoding="utf-8") as fh:    # brightdata scrape <product URL> -o page.md
    print(categories_from_markdown(fh.read()))
```

The `\s` at the end of the first pattern is essential, because the footer puts U+00A0, a non-breaking space, immediately after `to`. The same pattern written with a literal space finds no match. With `\s`, the parser returns the category path:

```none
[{'category_id': '13',     'url': '.../category/13/home-improvement.html'},
 {'category_id': '5',      'url': '.../category/5/electrical-equipments-supplies.html'},
 {'category_id': '141914', 'url': '.../category/141914/circuit-breakers.html'}]
```

Getting the category path needs one Web Unlocker request per product. Run the parser on the products whose taxonomy you need, and store the result with `item_id` as the key. The footer wording can change without notice, so check the `CRUMB` pattern first if the parser returns an empty list.

**[Scraper Studio](/products/web-scraper/studio) handles a custom field list.** When you describe the data you need, its AI generates a ready-to-run scraper with proxies and unblocking built in, and you can add or change output fields later. For price monitoring specifically, the [AliExpress Price Scraper API](/products/web-scraper/aliexpress/price) collects AliExpress pricing data so you can track how prices change.

## Detecting page and schema changes

Every failure seen on this target was silent, so monitor the page itself. The page’s bootstrap script contains a build version, `cosmos_prefetch_mtop version 20240605.195626.856`, and when that version changes, the prefetch config can change with it. A fingerprint detects that change directly. Without one, your own scraper would only show it as null values.

The 2 fingerprints below check 3 stages (the HTML shell, the rendered page, and the delivered row), and neither depends on parsing product fields:

```none
import hashlib, re

def shell_fingerprint(html):
    """From an unrendered GET. Detects a bootstrap or prefetch-config change."""
    view = re.search(r"viewName\s*=\s*'([^']+)'", html)
    api  = re.search(r"api:\s*'(mtop\.[^']+)'", html)
    ver  = re.search(r"cosmos_prefetch_mtop version ([\d.]+)", html)
    return {"viewName": view.group(1) if view else None,
            "prefetch_api": api.group(1) if api else None,
            "gop_version": ver.group(1) if ver else None}

def row_fingerprint(row):
    """From a delivered row. Confirms the field set your pipeline expects."""
    keys = sorted(k for k in row if k not in ("input", "timestamp"))
    return {"fields_sha1": hashlib.sha1(",".join(keys).encode()).hexdigest()[:12]}
```

On real captures, both fingerprints returned stable values:

```none
shell   unrendered GET : viewName=newDetail  api=mtop.aliexpress.pdp.pc.query  gop=20240605.195626.856
shell   rendered DOM   : viewName=newDetail  api=mtop.aliexpress.pdp.pc.query  gop=20240605.195626.856
row     1005012408061151 : fields_sha1=154f2f84da5f
row     1005001398867186 : fields_sha1=154f2f84da5f
row     1005011666876666 : fields_sha1=154f2f84da5f
```

**A third fingerprint of the captured payload looks useful, but it can cause false positives.** Hashing the module set seems like the same idea, but the module count varied with the listing and the time of capture, not only with a new deployment. The captured payloads had 28, 27, and 28 modules, and the `PRICE` key set differed between 2 of them.

The count changed within one product too. The same product ID returned 27 modules and then 26 on 2 captures minutes apart, so even a per-product history needs a threshold rather than an equality check. Base your alerts on the shell and row fingerprints, because their values stayed the same in every capture.

## When to buy the AliExpress dataset instead

The 3 methods all start from URLs you already have. If your question is about the whole market, start with the ready-to-use dataset:

*The filter panel lets you select records before you pay for them.*

The [AliExpress dataset](/products/datasets/aliexpress) reduces selection work and collection time. You filter the dataset to the records you need, in the marketplace or through the [Filter Dataset API](https://docs.brightdata.com/api-reference/marketplace-dataset-api/filter-dataset), and pay only for your filtered snapshot. Those records already exist, so there’s no collection run to wait for. You can buy it once or on a refresh schedule you choose, and current prices are on the [datasets pricing page](/pricing/datasets).

Estimate the delivery size before you order it. Records in the dataset sample average 12.75 KB as delivered JSON, so plan for about 1.3 GB per 100,000 records. The dataset also delivers in Parquet, and the same sample converted to Parquet was almost 8 times smaller. That size difference can determine whether you query the file in place or stage it first.

**Use the dataset for market-wide assortment and coverage questions, and use the Scraper API for the specific listings you track.** The dataset refreshes on the schedule you choose, and a Scraper API collection returns each listing at the moment you run it.

## Final thoughts

AliExpress’s signing scheme fits in one function, but in testing, the gateway blocked calls from one IP address within the first dozen requests, even with a correct signature. Because a block can return a success status code, base every check on the `bxpunish` header and on a field that every product has, such as the price. The site’s robots.txt doesn’t disallow the public `/item/` path, so that’s the path to scrape at scale.

Bright Data’s AliExpress Scraper API returns structured records for those product pages, the Browser API captures each variant’s price, and the AliExpress dataset suits whole-market questions. Start on the [free tier](https://docs.brightdata.com/general/account/billing-and-pricing/free-tier), which includes 5,000 requests a month with no credit card required. Test it with 40 URLs from your catalog, and check the records against the fields your pipeline needs.

## FAQ

### Does AliExpress have an API for product data?

Yes, for sellers, affiliates, and approved dropshippers. On the [AliExpress Open Platform](https://openservice.aliexpress.com/doc/doc.htm), a seller authorizes an app for their own store through OAuth 2.0, and the affiliate API covers the promotional catalog. None of them covers the whole marketplace, so market-level pricing data needs [ecommerce data collection](/use-cases/ecommerce).

### How do I get an AliExpress product URL or ID?

Product URLs follow the pattern `/item/<item_id>.html`, and discovery starts from `/w/wholesale-<keyword>.html`. IDs depend on the host. One product has the ID `1005001398867186` on `.com` and `3256801212552434` on `.us`. Key each product by its final URL after the regional redirect, so it doesn’t appear under 2 IDs.

### Is scraping AliExpress product data free?

Yes, at small scale. Bright Data’s free tier includes 5,000 requests a month with no credit card required, and it covers the Scraper API, Web Unlocker, and the Browser API, so you can test them on your own URLs first. Playwright and curl\_cffi are open source. At production scale, you pay mostly for IP addresses, because 2 runs of 60 signed API calls from one IP address got data 7 and 15 times.

### What does an AliExpress scraper API cost?

Bright Data’s AliExpress Scraper API is priced per delivered record, and Web Unlocker is priced per successful request, so you pay only for results. The Browser API is priced by traffic, so page weight predicts its cost. A product page weighed about 3 MB in testing. Current prices are on Bright Data’s [pricing page](/pricing).

### Why does my AliExpress scraper return empty fields?

Because the initial HTML has no prices or stock. `window.runParams` is an empty object and `<title>` is blank, so selector-based extractors match nothing and raise no error. The page’s JavaScript loads the fields later from `mtop.aliexpress.pdp.pc.query`. Either [execute the JavaScript](/blog/how-tos/scrape-dynamic-websites-python) or call that API directly.

### How do I avoid getting blocked scraping AliExpress?

Send requests through Bright Data’s Scraper API or Web Unlocker, which have built-in proxies and [AliExpress CAPTCHA solving](/products/web-unlocker/captcha-solver/aliexpress). In testing, neither a new cookie jar nor a 60-second wait removed a block from one IP address. Blocked requests also returned HTTP 200, so check for the `bxpunish` header, not the status code.

### Why do AliExpress prices change?

Because promotions are temporary and vary by country. In one test across 6 exit countries, one product’s discount ranged from 52.5% down to 4.1% off the list price, and the countries differed in whether prices included tax. Set one exit country for each price series and store it in the row.

### Is there an AliExpress MCP server?

Yes. Bright Data’s MCP Server lets an AI agent fetch AliExpress product pages through its `scrape_as_markdown` and `scrape_batch` tools, which handle unblocking and return markdown. In a test call, `scrape_as_markdown` returned the product’s price, rating, variants, and category path. The [AliExpress MCP page](/ai/mcp-server/aliexpress) explains setup.



Contact usStart for free

No credit card required











 [ ](https://www.linkedin.com/in/triposat/)

Satyam Tripathi

 Technical Writer



  5 years experience



Satyam Tripathi helps SaaS and data startups turn complex tech into actionable content, boosting developer adoption and user understanding.



Expertise

  Python   Developer Education   Technical Writing



 [ View all articles ](https://brightdata.com/blog/authors/satyam-tripathi)











 Table of Contents







Dedicated Scraper APIs &amp; No-Code Scrapers

Over 1000 scrapers for all popular domains. Simplify your web scraping.

[See pricing](/pricing/web-scraper "See pricing")

Just want data? Skip scraping.

Hundreds of ready-to-use datasets from all popular domains.

[See pricing](/pricing/datasets "See pricing")







 [ ](https://news.ycombinator.com/submitlink?t=How+to+Scrape+AliExpress+Data+at+Scale&u=https://brightdata.com/blog/web-data/how-to-scrape-aliexpress) [ ](https://www.linkedin.com/shareArticle?mini=true&title=How+to+Scrape+AliExpress+Data+at+Scale&url=https://brightdata.com/blog/web-data/how-to-scrape-aliexpress) [ ](http://www.reddit.com/submit?title=How+to+Scrape+AliExpress+Data+at+Scale&url=https://brightdata.com/blog/web-data/how-to-scrape-aliexpress)







##  You might also be interested in

 [ ](https://brightdata.com/blog/web-data/auto-parts-and-tire-data "Auto parts and tire data: pricing, fitment, product matching, and market coverage")

 [Web Data





Satyam Tripathi

Technical Writer





### Auto parts and tire data: pricing, fitment, product matching, and market coverage

Why one part shows different prices across retailers, how to match listings by part number, and how Bright Data delivers auto parts and tire data.



 05-Oct-2026

 16 min read

 ](https://brightdata.com/blog/web-data/auto-parts-and-tire-data)

 [ ](https://brightdata.com/blog/web-data/audio-web-scraping "Audio Web Scraping for AI Processing: A Complete Tutorial")

 [Web Data





Antonello Zanini

Technical Writer





### Audio Web Scraping for AI Processing: A Complete Tutorial

Scrape audio at scale for AI processing with Bright Data. Learn format conversion and AI-powered transcription for enterprise-grade results.



 23-Sep-2026

 15 min read

 ](https://brightdata.com/blog/web-data/audio-web-scraping)

 [ ](https://brightdata.com/blog/ai/chrome-devtools-mcp-with-bright-data "Let chrome-devtools-mcp Control Cloud Anti-Detect Browsers")

 [AI





Antonello Zanini

Technical Writer





### Let chrome-devtools-mcp Control Cloud Anti-Detect Browsers

Combine chrome-devtools-mcp with Bright Data’s Browser API to enable AI agents to control cloud anti-detect browsers seamlessly.



 23-Sep-2026

 10 min read

 ](https://brightdata.com/blog/ai/chrome-devtools-mcp-with-bright-data)
