# AliExpress Products Scraper — Prices, Sellers, SKUs & Stock (`scrapersdelight/aliexpress-products-scraper`) Actor

Search AliExpress by keyword or product URL and get every product as one row: sale and original price, discount, rating, orders, store and seller (id, positive rate, opened date), per-SKU prices and stock, shipping fee and delivery date. No login, no browser, no CAPTCHA solving.

- **URL**: https://apify.com/scrapersdelight/aliexpress-products-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** E-commerce, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.50 / 1,000 per product returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## AliExpress Products Scraper — Prices, Sellers, SKUs & Stock

Search [AliExpress](https://www.aliexpress.com) by keyword and get one clean row per product:
**sale price, original price, discount %, rating, orders, selling-point tags, the full image
gallery, the leaf `categoryId`, the `storeName`, the ships-from country and a delivery-days /
delivery-date estimate** — all of it read out of the search page itself, so it does not depend on
any second request clearing.

> **⚠️ Read this first — the seller / SKU / stock columns are not currently deliverable.**
> Those fields come from AliExpress's signed product-detail API, and as of **2026-08-27** that API
> refuses every request this Actor can make: the *first, unsigned, token-minting* call comes back
> `FAIL_SYS_USER_VALIDATE` even from a clean IP that has never contacted the host, so a signed call
> cannot even be constructed. Measured 0/5 on Apify datacenter, 0/4 on Apify RESIDENTIAL and 0/3
> from a clean home IP. It is **not** a rate limit, so waiting, lowering concurrency and swapping
> proxies do not help. **`fetchProductDetails` therefore ships OFF**, and the columns it would fill
> (`skus[]`, `totalStock`, `reviewCount`, `storeId`, `sellerId`, `sellerPositiveRate`,
> `shippingFee`, `properties`, …) ship as `null` with `detailFetched: false`. They are still in the
> schema so the column set never moves, and **you are never charged for a row that was not enriched**.
> Turn the toggle on if you want to probe whether it has reopened. Everything listed in the
> paragraph above is unaffected.

One source, no browser: AliExpress's own search page (60 product cards per page, read from the
`_init_data_` blob the page ships to itself, including the per-card `trace` blobs most scrapers
never open). **No login, no AliExpress account, no API key, no browser, and no CAPTCHA is ever
solved** — a challenged session is simply retired and the page is retried on a fresh one. The Actor
runs in **512 MB**.

**Scope: one keyword walks 60 products per results page, up to `maxPagesPerQuery` pages** (default
20 ≈ 1,200 products per keyword; hard maximum 200). AliExpress advertises tens of thousands of
results for a common term, but a search stops on its own when a page comes back short — the Actor
detects that and moves to the next keyword instead of burning your budget on empty pages. Add more
keywords to go wider; everything is de-duplicated on `productId` **run-wide, before billing**.

```json
{
  "searchQueries": ["wireless earbuds"],
  "fetchProductDetails": false,
  "shipToCountry": "US",
  "currency": "USD",
  "maxItems": 60
}
```

Click **Try for free** and hit **Start** — that block is literally the input the Actor ships with.
It returns **60 products for $0.15**, flat, with no second-request lottery in the price.

***

### The wedge: the fields other scrapers leave on the table

Most AliExpress scrapers on the Store return what the search *card* obviously shows: title, price,
rating, "10,000+ sold". But AliExpress hangs a **second data layer off every card** — `images[]`
and a `trace` object with `utLogMap`, `pdpParams.pdp_cdi` and `custom.p4pExtendParam` — and that
layer carries fields most parsers never open. This Actor reads it, so these arrive **on a plain
search run, with no extra request and no second wall to clear** (fill measured over 240 cards
across four queries, 2026-08-27):

| Field | Fill | Where it comes from |
|---|---|---|
| `images[]` — the gallery, 5–8 per card | **99.6%** | `images[]` on the card |
| `categoryId` — the **leaf** category id | **100%** | `trace.utLogMap.categoryId`, last segment |
| `shipsFrom` — warehouse country, ISO-2 | **94%** | `trace.pdpParams.pdp_cdi` (URL-encoded JSON) |
| `storeName` | **59%** | `trace.custom.p4pExtendParam.store_name` |
| `deliveryDaysMin` / `deliveryDaysMax` / `deliveryDate` | **62%** (25–75% by keyword) | `trace.utLogMap.delivery_info` |

Two of those are worth calling out:

- **`shipsFrom` separates a China-warehouse 20-day item from a US/EU-warehouse 4-day one** — the
  single biggest driver of whether a product is sellable — and you get it without a product-page
  request. Sample split: `CN` 173, `US` 52. Note it is the ship-from of **that card's SKU**; a
  multi-warehouse listing can offer others.
- **`categoryId` is the leaf id, cross-checked two ways**: the last segment of the `utLogMap` path
  equals `trace.exposure.postCategoryId` on 240/240 cards, and on a product where we also held a
  product-detail payload the leaf (`63705`) matched the detail API's `categoryId` exactly.

`storeName` at 59% is a partial column, not a complete store dataset — it is present on **every**
sponsored card and on **45%** of organic ones. Treat it as a strong hint for grouping, and read
`detailFetched` before assuming any store field is authoritative.

**What is NOT on the card, and is therefore not currently deliverable:** `storeId`, `storeUrl`,
`sellerId`, `sellerCountry`, `storeOpenedAt`, `sellerPositiveRate`, `sellerLevel`,
`sellerFeedbackCount`, `sellerScores`, `storeFollowers`, `reviewCount`, `wishlistCount`, `skus[]`,
`skuCount`, `totalStock`, `inStock`, `minSkuPrice`, `maxSkuPrice`, `shippingFee`,
`shippingFeeText`, `freeShipping`, `freeShippingWithChoice`, `shippingProvider`, `properties`,
`storeLogo`, `storeYearsOpen`, `topRatedSeller`. We verified by needle-sweeping all 240 cards: none
of the underlying values appears anywhere in the search payload. They need the product-detail API,
which is blocked — see the banner at the top and *Honest limits*.

- **Enrichment you only pay for when it worked.** The `product-detail-enriched` event is charged
  **per row where the detail API actually returned data**. A product that could not be enriched
  still ships as a search-level row, marked `detailFetched: false`, and is **never charged the
  enrichment event**. You are never billed for a null — which, while the API is blocked, means you
  are simply never charged that event at all.
- **Filters applied by AliExpress, before anything is fetched.** `minPrice`, `maxPrice` and
  `sortBy` are pushed onto AliExpress's own search URL, so out-of-band products are normally never
  returned, fetched or billed. The filtering is AliExpress's, not ours — the Actor does not re-check
  prices locally, so if AliExpress returns a near-band item it will be billed. Filter on `salePrice`
  in your own pipeline if the band is hard. `excludeSponsored` drops paid "Ad" cards before billing
  too — AliExpress mixes them into every page (**measured: 60 of 240 cards across four searches**).
- **One record type, stable columns.** With enrichment off, the seller / SKU / shipping / stock
  columns are still present and `null`. A CSV export or a downstream table keeps the same schema
  between runs whatever you toggle.

***

### Read this before you buy rows

1. **Buy this for the search-level row, not for the seller/SKU columns.** As of **2026-08-27** the
   product-detail API is refusing every request, so `fetchProductDetails` ships **off** and roughly
   half the schema ships `null`. What you reliably get per row: `title`, `salePrice`,
   `originalPrice`, `discountPercent`, `rating`, `ordersText`/`ordersCount`, `sellingPoints`,
   `isSponsored`, `listedAt`, `imageUrl`, `images[]`, `categoryId`, `shipsFrom`, `storeName`
   (partial), `deliveryDaysMin`/`Max`, `deliveryDate` (partial), and full search provenance. If you
   need `skus[]`, `totalStock`, `reviewCount` or the seller-identity columns, this Actor cannot
   currently give them to you — do not buy rows expecting them.
2. **The block is not a rate limit, so none of the usual knobs fix it.** The *first, unsigned,
   token-minting* call to the detail API returns `FAIL_SYS_USER_VALIDATE` and never sets an
   `_m_h5_tk` cookie, from a **clean IP that had never contacted the host**. Measured 0/5 Apify
   datacenter, 0/4 Apify RESIDENTIAL, 0/3 clean home IP. Lower `maxConcurrency`, longer waits,
   different proxies and re-running later have all been tried and none of them changes it.
3. **Do NOT switch the proxy to RESIDENTIAL to "fix" enrichment.** It does not, and datacenter is
   the right rung for the search pages that carry your actual data. Residential is used
   automatically as a small capped fallback for *search* pages when a datacenter page comes back
   challenged. (One caveat measured 2026-08-27: the punish is not confined to the detail API — a
   home IP got x5sec-walled on the **search** path after about six page fetches, while Apify
   RESIDENTIAL cleared the same queries first try. The Actor detects a walled search page and
   retries on a fresh session, but a long run can still lose pages.)
4. **`ordersCount` is a floor, not an exact number.** AliExpress prints buckets — "10,000+ sold" —
   so `ordersText` is the literal string and `ordersCount` is the number parsed out of it (10000).
   Verified over 216 cards where AliExpress's own internal exact counter was also present:
   `ordersCount` was **never higher** than the true count (104 exactly equal, 112 lower). Read it as
   "at least this many". `sellerSalesText` is the same bucketed string AliExpress prints on the
   product page's rating module — it duplicates `ordersText` on search-level rows.
5. **Product URLs are not usable right now.** A URL row has no search card behind it, so everything
   except `productId` and `productUrl` would have to come from the blocked detail API. Use
   `searchQueries` instead; the run logs a warning if you paste URLs anyway.
6. **Prices are what a logged-out visitor in your `shipToCountry` sees.** Account-specific coupons,
   app-only prices, "welcome deal" pricing and basket-level promotions are not visible without
   logging in, and this Actor never logs in. `freeShippingWithChoice: true` means shipping is free
   only on a qualifying *Choice* basket, not on this item alone — read it as a condition, not a fee.
7. **A search stops when AliExpress runs out.** A short page (fewer than half of 60 cards), a page
   with zero cards, or the advertised total being reached ends that keyword cleanly with a log line,
   not an error. Deep pagination on a narrow term will simply stop early.

***

### What you get

**One row per unique product, 55 fields**, de-duplicated run-wide on `productId`. The search card
fills the listing fields; the detail API — **currently blocked** — would fill the rest and could
also *refine* `title`, `imageUrl`, `currency`, `salePrice`, `originalPrice`, `discountPercent`
and `rating` with the product page's own numbers. Every field below is emitted on every row — as
`null` when it is not available — so the column set never shifts, whatever you toggle and whatever
AliExpress is blocking that day.

| Group | Fields |
|---|---|
| **Identity** | `productId`, `productUrl`, `title`, `imageUrl`, `images[]`, `categoryId`, `listedAt` |
| **Price** | `currency`, `salePrice`, `originalPrice`, `discountPercent`, `minSkuPrice`, `maxSkuPrice` |
| **Demand** | `rating`, `reviewCount`, `ordersText`, `ordersCount`, `sellerSalesText`, `wishlistCount` |
| **Store & seller** | `storeName`, `storeId`, `storeUrl`, `storeLogo`, `sellerId`, `sellerCountry`, `storeOpenedAt`, `storeYearsOpen`, `topRatedSeller`, `sellerPositiveRate`, `sellerLevel`, `sellerFeedbackCount`, `sellerScores`, `storeFollowers` |
| **SKUs & stock** | `skus[]` (`skuId`, `attributes`, `salePrice`, `originalPrice`, `stock`, `available`, `maxPerOrder`), `skuCount`, `totalStock`, `inStock` |
| **Shipping** | `shippingFee`, `shippingFeeText`, `freeShipping`, `freeShippingWithChoice`, `deliveryDate`, `deliveryDaysMin`, `deliveryDaysMax`, `shipsFrom`, `shippingProvider` |
| **Listing extras** | `sellingPoints[]`, `isSponsored`, `properties` |
| **Provenance** | `detailFetched`, `searchQuery`, `searchPage`, `searchPosition`, `shipToCountry`, `scrapedAt` |

The dataset ships with two saved **table views**. **AliExpress products** (the default) shows only
columns that fill on every run — Product, Sale price, Original, Off %, Currency, Rating, Orders,
Orders (min), Store, Ships from, Ship days min/max, Delivery by, Category id, Listed, Ad, Enriched,
URL. **Store, seller, SKUs & shipping** shows the enrichment-only columns, which are empty while the
detail API is blocked. The Console and a CSV export both open on the first one.

#### Which fields are always filled, and which depend on enrichment

The fill percentages below were measured on **240 search cards across four keywords on 2026-08-27**
(`wireless earbuds`, `bluetooth speaker`, `phone case`, `running shoes`) — one sample, not a
guarantee, and the delivery fields in particular swing hard by keyword (`phone case` returned 25%
where the other three returned ~75%). **Every run also prints its own counts in the log** (search
pages ok/failed, rows enriched vs not, duplicates dropped, sponsored rows filtered, and the
per-error-code breakdown of retried detail responses). Plan against the categories below, then read
your own run's log.

| Category | Fields | Filled when |
|---|---|---|
| **Always present** | `productId`, `productUrl`, `currency`, `shipToCountry`, `detailFetched`, `scrapedAt`, `sellingPoints[]`, `isSponsored` | Every row. `currency` falls back to your `currency` input; `sellingPoints` is `[]` and `isSponsored` is `false` rather than null. |
| **From the search card** | `title`, `imageUrl`, `salePrice`, `originalPrice`, `discountPercent`, `rating`, `ordersText`, `ordersCount`, `listedAt`, `searchQuery`, `searchPage`, `searchPosition` | Every row that came from a keyword search. Rows from `productUrls` have no card, so `searchQuery`/`searchPage`/`searchPosition` are `null` and the rest arrive via the detail API. |
| **From the card's `trace` layer** | `images[]` (99.6%), `categoryId` (100%), `shipsFrom` (94%), `storeName` (59%), `deliveryDaysMin`/`deliveryDaysMax`/`deliveryDate` (62%, 25–75% by keyword), `sellerSalesText` (100%) | Every row from a keyword search, at the fill rates shown (n=240 cards, 2026-08-27). No extra request, so these survive the detail API being blocked. `sellerSalesText` duplicates `ordersText` on these rows. |
| **Enrichment-dependent** | the whole Store & seller group except `storeName`, the whole SKUs & stock group, `shippingFee`, `shippingFeeText`, `freeShipping`, `freeShippingWithChoice`, `shippingProvider`, plus `reviewCount`, `wishlistCount` and `properties` | Only where `detailFetched: true` — which, while the detail API is blocked, is **no rows**. Filter on that flag rather than on any single field. |
| **Best-effort even when enriched** | `sellerScores`, `properties`, `storeLogo`, `shippingProvider`, `topRatedSeller`, `storeYearsOpen`, `maxPerOrder` inside `skus[]` | AliExpress does not return every module for every listing — these are `null` where the product page itself does not carry them. A real zero (e.g. `shippingFee: 0` on free shipping) is a zero, never a null. |

***

### How to run it

#### 1. The shipped default — one keyword, one page

```json
{
  "searchQueries": ["wireless earbuds"],
  "maxItems": 60
}
```

60 products, one results page: title, prices, discount, rating, orders, tags, image gallery,
category id, ships-from, delivery estimate and store name where the card carries it. **$0.15**,
flat — enrichment is off by default, so there is no variable half to the bill.

#### 2. Dropshipping research — best sellers in a price band

```json
{
  "searchQueries": ["led strip lights", "phone case", "car phone holder"],
  "sortBy": "orders",
  "minPrice": 2,
  "maxPrice": 15,
  "excludeSponsored": true,
  "maxItemsPerQuery": 120,
  "maxItems": 300
}
```

`sortBy: "orders"` is AliExpress's *most orders first* ordering — the dropshipping-research choice.
The price band and the sponsored filter are applied **before** anything is fetched or billed, so a
$40 listing and an Ad card never appear on your invoice. `maxItemsPerQuery: 120` = two pages per
keyword, so one broad term cannot eat the whole 300-product budget. **$0.75.**

#### 3. Supplier vetting — cheapest first, seller fields front and centre

```json
{
  "searchQueries": ["bluetooth speaker waterproof"],
  "sortBy": "price_asc",
  "shipToCountry": "DE",
  "currency": "EUR",
  "maxItems": 200
}
```

Sort by price ascending, then group your own dataset by `storeName` and `shipsFrom`. **Note:** the
seller-quality columns this recipe used to lean on (`sellerPositiveRate`, `storeOpenedAt`,
`sellerScores`) come from the blocked detail API and will be `null` — what you can do today is spot
the same `storeName` across many cheap listings and separate CN-warehouse from EU/US-warehouse
supply. Country and currency are pinned in AliExpress's locale cookie on the search request, so the
result should not depend on the exit IP. The Actor does not verify the returned currency — check
`currency` on the rows if you are mixing regions. **$0.50.**

#### 4. Price monitor on a keyword ⚠️ (the product-URL version is not usable right now)

```json
{
  "searchQueries": ["tws earbuds anc"],
  "sortBy": "orders",
  "maxItems": 60
}
```

Save it as a Task, attach a daily Schedule, and diff `salePrice` / `discountPercent` /
`ordersCount` / `deliveryDaysMax` per `productId` across runs. **$0.15 / day.**

The `productUrls` input still exists and still accepts `/item/<id>.html` URLs, aliexpress.us URLs
and bare numeric ids — but a URL row has no search card behind it, so it can only be filled by the
blocked detail API. Until that reopens, pasting URLs returns rows carrying nothing but `productId`
and `productUrl`, and the run says so in the log. Watch products by keyword instead.

#### 5. Cheap catalogue sweep — search-level rows only

```json
{
  "searchQueries": ["kitchen gadgets"],
  "fetchProductDetails": false,
  "maxPagesPerQuery": 10,
  "maxItems": 600
}
```

Enrichment off (the default): title, price, discount, rating, orders, tags, gallery, category id,
ships-from and delivery estimate at **$0.0025 a row — $1.50 for 600** — and no detail-API calls at
all, so the run is fast and never stalls on a challenge. The seller/SKU/stock columns are present
and `null`.

#### Scheduling and integrations

Save the input as a **Task** (Console → the Actor → *Save as Task*), then attach an Apify
**Schedule**. The dataset is available over the REST API and through the standard Apify
integrations (Zapier, Make, n8n, webhooks, MCP) like any other Actor. From the API:

```bash
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~aliexpress-products-scraper/runs?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"searchQueries":["wireless earbuds"],"sortBy":"orders","shipToCountry":"US","currency":"USD","maxItems":120}'
```

***

### Sample row

#### What a run returns today (the shipped default, enrichment off)

Every key the Actor emits is present; the enrichment-only ones are `null`. Values are illustrative.

```jsonc
{
  "productId": "1005012258457853",
  "productUrl": "https://www.aliexpress.com/item/1005012258457853.html",
  "title": "TWS Wireless Earbuds Bluetooth 5.3 Headphones with Charging Case",
  "imageUrl": "https://ae-pic-a1.aliexpress-media.com/kf/S8f2c1e0a4b3d4e0f9c1a2b3.jpg",
  "currency": "USD",
  "salePrice": 9.24,
  "originalPrice": 23.68,
  "discountPercent": 61,
  "rating": 4.7,
  "ordersText": "10,000+ sold",
  "ordersCount": 10000,              // a floor: the bucket AliExpress prints, never an overstatement
  "sellingPoints": ["Free shipping", "Choice"],
  "isSponsored": false,
  "listedAt": "2024-11-08",
  "shipToCountry": "US",

  // --- read off the search card's second data layer, no extra request ---
  "images": [                         // 99.6% of cards, 5-8 entries
    "https://ae-pic-a1.aliexpress-media.com/kf/S8f2c1e0a4b3d4e0f9c1a2b3.jpg",
    "https://ae-pic-a1.aliexpress-media.com/kf/Sa1b2c3d4e5f60718293a4b5.jpg"
  ],
  "sellerSalesText": "10,000+ sold",  // same bucketed string as ordersText on a search-level row
  "categoryId": "63705",              // 100% — the LEAF id
  "storeName": "TWS Audio Direct Store", // 59% — null on the other 41%
  "deliveryDaysMin": 6,               // 62% — these four move together
  "deliveryDaysMax": 13,
  "deliveryDate": "2026-09-09",
  "shipsFrom": "CN",                  // 94% — ISO-2, for THIS card's SKU

  // --- enrichment-only: null while the product-detail API is blocked ---
  "reviewCount": null,
  "inStock": null, "totalStock": null, "skuCount": null, "skus": null,
  "minSkuPrice": null, "maxSkuPrice": null,
  "storeId": null, "storeUrl": null, "storeLogo": null,
  "sellerId": null, "sellerCountry": null, "storeOpenedAt": null, "storeYearsOpen": null,
  "topRatedSeller": null, "sellerPositiveRate": null, "sellerLevel": null,
  "sellerFeedbackCount": null, "sellerScores": null, "storeFollowers": null, "wishlistCount": null,
  "shippingFee": null, "shippingFeeText": null, "freeShipping": null,
  "freeShippingWithChoice": null, "shippingProvider": null, "properties": null,

  "detailFetched": false,
  "searchQuery": "wireless earbuds",
  "searchPage": 1,
  "searchPosition": 7,
  "scrapedAt": "2026-08-27T14:31:09.117Z"
}
```

#### What a row would look like if enrichment reopened

**Not currently obtainable** — kept here so you can see the full schema and judge whether it is
worth watching this Actor. Values illustrative.

```jsonc
{
  "productId": "1005012258457853",
  "productUrl": "https://www.aliexpress.com/item/1005012258457853.html",
  "title": "TWS Wireless Earbuds Bluetooth 5.3 Headphones with Charging Case",
  "imageUrl": "https://ae01.alicdn.com/kf/S8f2c1e0a4b3d4e0f9c1a2b3c4d5e6f7g.jpg",
  "currency": "USD",
  "salePrice": 9.24,
  "originalPrice": 23.68,
  "discountPercent": 61,
  "rating": 4.7,
  "ordersText": "10,000+ sold",
  "ordersCount": 10000,
  "sellingPoints": ["Free shipping", "Choice"],
  "isSponsored": false,
  "listedAt": "2024-11-08",
  "shipToCountry": "US",

  "images": [
    "https://ae01.alicdn.com/kf/S8f2c1e0a4b3d4e0f9c1a2b3c4d5e6f7g.jpg",
    "https://ae01.alicdn.com/kf/Sa1b2c3d4e5f60718293a4b5c6d7e8f90.jpg"
  ],
  "reviewCount": 3184,
  "sellerSalesText": "600+ sold",
  "categoryId": "63705",

  "inStock": true,
  "totalStock": 8412,
  "skuCount": 4,
  "skus": [
    {
      "skuId": "12000029884517291",
      "attributes": { "Color": "Black", "Ships From": "China" },
      "salePrice": 9.24,
      "originalPrice": 23.68,
      "stock": 3120,
      "available": true,
      "maxPerOrder": 200
    },
    {
      "skuId": "12000029884517292",
      "attributes": { "Color": "White", "Ships From": "China" },
      "salePrice": 9.24,
      "originalPrice": 23.68,
      "stock": 2894,
      "available": true,
      "maxPerOrder": 200
    },
    {
      "skuId": "12000029884517293",
      "attributes": { "Color": "Blue orange-AI", "Ships From": "United States" },
      "salePrice": 11.86,
      "originalPrice": 23.68,
      "stock": 2398,
      "available": true,
      "maxPerOrder": 200
    },
    {
      "skuId": "12000029884517294",
      "attributes": { "Color": "Pink", "Ships From": "China" },
      "salePrice": 9.99,
      "originalPrice": 23.68,
      "stock": 0,
      "available": false,
      "maxPerOrder": null
    }
  ],
  "minSkuPrice": 9.24,
  "maxSkuPrice": 11.86,

  "storeName": "TWS Audio Direct Store",
  "storeId": "1102358041",
  "storeUrl": "https://www.aliexpress.com/store/1102358041",
  "storeLogo": "https://ae01.alicdn.com/kf/Sc3d4e5f60718293a4b5c6d7e8f901234.png",
  "sellerId": "234618907",
  "sellerCountry": "China",
  "storeOpenedAt": "2019-06-14",
  "storeYearsOpen": null,
  "topRatedSeller": true,
  "sellerPositiveRate": 96.8,
  "sellerLevel": "Top Brand",
  "sellerFeedbackCount": 41267,
  "sellerScores": {
    "Item as described": "4.8",
    "Communication": "4.9",
    "Shipping speed": "4.8"
  },
  "storeFollowers": 18422,
  "wishlistCount": 6103,

  "shippingFee": 0,
  "shippingFeeText": "Free shipping",
  "freeShipping": true,
  "freeShippingWithChoice": true,
  "deliveryDate": "Sep 12",
  "deliveryDaysMin": 8,
  "deliveryDaysMax": 15,
  "shipsFrom": "CN",
  "shippingProvider": "AliExpress Standard Shipping",

  "properties": {
    "Brand Name": "NoEnName_Null",
    "Bluetooth Version": "5.3",
    "Battery Capacity": "300mAh",
    "Waterproof": "IPX5"
  },

  "detailFetched": true,
  "searchQuery": "wireless earbuds",
  "searchPage": 1,
  "searchPosition": 7,
  "scrapedAt": "2026-08-22T14:31:09.117Z"
}
```

Fields people misread:

- **`salePrice` vs `minSkuPrice`.** `salePrice` is the price of the SKU AliExpress preselects;
  `minSkuPrice` / `maxSkuPrice` are the cheapest and dearest variants. A listing advertised at
  "$9.24" can have a $11.86 variant — that is the spread you need for margin maths.
- **`discountPercent`** comes from AliExpress's own discount rate where the detail API gives one,
  and is otherwise derived from `salePrice` vs `originalPrice`. `originalPrice` is AliExpress's
  reference price, which is marketing, not a historical sale price.
- **`freeShipping` vs `freeShippingWithChoice`.** The first means this shipping option costs 0 for
  your `shipToCountry`; the second means it is free only inside a qualifying *Choice* basket.
- **`storeId` vs `sellerId`.** `storeId` is the number in the store URL (joins to store pages);
  `sellerId` is the seller account behind it. One seller can run more than one store.
- **`storeYearsOpen`** is AliExpress's own `sellerInfo.openedYear` value, passed through unmodified
  — the Actor does not derive it from `storeOpenedAt`. Read it alongside `storeOpenedAt` rather than
  trusting it as a year count.
- **`shipsFrom`** is the warehouse country code (`CN`, `US`, `ES`…), while `sellerCountry` is where
  the seller is registered. They differ constantly, and the warehouse is the one that decides
  delivery time. On a search-level row it is the ship-from of **that card's SKU**, so a
  multi-warehouse listing may offer others; if enrichment ever runs, the detail API's value (the
  default SKU's) overwrites it and the two can legitimately disagree.
- **`sellerSalesText`** is AliExpress's bucketed "sold" line. On a search-level row it is filled
  from the card and is therefore the **same string as `ordersText`** — it is only a distinct value
  when the product-detail API's rating module answers. (An earlier `sellerSimilarItemOrders` column
  was removed on 2026-08-27: AliExpress dropped the "…from this seller: <n>" copy it was parsed
  from, so it could only ever have been `null`.)
- **`detailFetched: false`** means the detail API never answered for that product — the row is a
  search-level row, not a broken one, and it was not charged the enrichment event.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| **🎯 What to scrape** | | | |
| `searchQueries` | string list | `["wireless earbuds"]` | Keywords to search, one per line. Each walks the results 60 products per page in the chosen sort order. Empty = the documented sample. |
| `productUrls` | string list | `[]` | AliExpress product pages to scrape directly — `/item/<id>.html`, aliexpress.us URLs, or bare numeric ids. **Can only be filled by the blocked detail API**, so these currently return id + URL only. Use keywords instead. |
| `sortBy` | select | `default` | `default` (best match) · `orders` (best selling) · `price_asc` · `price_desc`. Keyword searches only. |
| `minPrice` / `maxPrice` | number | empty | Price band in your `currency`, applied by AliExpress on the search page — filtered-out products are never fetched or billed. |
| `excludeSponsored` | boolean | `false` | Drop paid "Ad" cards before they are fetched or billed. Off = keep them with `isSponsored: true`. |
| **📦 Output shape** | | | |
| `fetchProductDetails` | boolean | **`false`** | Try the product-detail API for every product (store id, seller, SKUs, stock, shipping fee, properties). **Blocked as of 2026-08-27 — off by default.** Off = search-card fields only, billed at the product rate alone. Columns stay identical either way. |
| `shipToCountry` | string | `US` | Two-letter code AliExpress prices and ships to (`US`, `GB`, `DE`, `FR`, `ES`, `IT`, `NL`, `AU`, `CA`, `BR`, `MX`…). Drives shipping fee, delivery date, stock and some prices. |
| `currency` | string | `USD` | Three-letter code for every price in the output (`USD`, `EUR`, `GBP`, `AUD`, `CAD`, `BRL`, `MXN`…). Price filters are read in this currency. |
| **💷 Limits & cost** | | | |
| `maxItems` | integer | `60` | **Your hard cost ceiling.** Unique products across all keywords and URLs. At the default (enrichment off): 60 = $0.15, 500 = $1.25, 1,000 = $2.50. |
| `maxItemsPerQuery` | integer | `0` | Cap per keyword so one broad term cannot use the whole budget. 0 = no per-keyword cap. 60 per page, so 120 = two pages. |
| `maxPagesPerQuery` | integer | `20` | How deep to walk each keyword. 20 pages ≈ 1,200 products. Maximum 200. |
| **⚙️ Advanced** | | | |
| `maxConcurrency` | integer | `4` | Parallel detail requests, 1–8. Each worker owns one proxy session. No effect with enrichment off (the default), and it does not get past the current block, which is not rate-based. |
| `proxyConfiguration` | proxy | Apify **datacenter** | The measured rung for the search pages that carry your data, with an automatic capped RESIDENTIAL retry for a challenged page — see the ladder below. |

***

### Pricing

**$0.0025 per product returned — $2.50 per 1,000** — charged on the **`product-scraped`** event.
**$0.002 per product enriched** — charged on **`product-detail-enriched`**, and **only for rows
where AliExpress's product-detail API actually returned data**. No monthly fee from this Actor.

**Enrichment is off by default and the API it needs is blocked, so in practice you pay a flat
$0.0025 a row today.** If it reopens and you turn it on, a fully-detailed product is $0.0045.

| Run | Products | Cost today (enrichment off) | Ceiling if enrichment reopened |
|---|---|---|---|
| The shipped default | 60 | **$0.15** | $0.27 |
| Dropshipping sweep, 3 keywords × 120 | 300 | **$0.75** | $1.35 |
| Supplier vetting, one keyword | 500 | **$1.25** | $2.25 |
| Full catalogue pull | 1,000 | **$2.50** | $4.50 |
| Catalogue sweep | 600 | **$1.50** | $2.70 |
| Daily keyword monitor | 60 | **$0.15 / day** | $0.27 / day |

The right-hand column is a ceiling that assumes **every** row enriched — it is not currently
reachable. Plan against the middle column.

- **You are charged per row delivered.** Rows shipped and rows billed are always the same number.
- **Duplicates never cost you twice.** A product that appears under several keywords, or on more
  than one page, is de-duplicated on `productId` **before** billing, and the run asserts uniqueness
  across everything it pushed, stopping with a status message that names the failure if that
  assertion ever trips.
- **Filtered rows are free.** Products removed by `minPrice` / `maxPrice` are filtered by
  AliExpress before they are ever fetched; products removed by `excludeSponsored` are dropped
  before billing. Neither appears on your invoice.
- **Enrichment only bills when it succeeded.** The `product-detail-enriched` event is charged for
  the enriched rows among those actually shipped. A row that failed every session ships as a
  search-level row and is charged the product event only.
- **A budget cap stops the crawl, not just the push.** The Actor reads your remaining charge
  headroom at startup and stops *fetching* at the affordable product count, so a capped run does
  not burn compute on rows it could never deliver. If the cap is hit mid-run it stops cleanly and
  says so — whole rows, never a half-billed dataset.

Why $0.0025 and not the $0.001 floor: a search page is not one request. It is fetched on a rotating
proxy session with up to four attempts and an automatic residential retry, because AliExpress
challenges the search path too — and what comes back is parsed down to the card's `trace` layer for
the gallery, the leaf category, the ships-from country, the store name and the delivery window,
which is the part other scrapers at this price do not give you.

***

### Honest limits

- **The product-detail API is blocked, and that is the biggest limit here.** Re-measured
  2026-08-27: the first, unsigned, token-minting call returns HTTP 200 with
  `ret: ["FAIL_SYS_USER_VALIDATE", "RGV587_ERROR::SM::…"]` and sets `x5secdata`/`x5sectag` but no
  `_m_h5_tk`, so a signed call cannot even be built. **0/5** Apify datacenter, **0/4** Apify
  RESIDENTIAL, **0/3** from a clean home IP that had never contacted the host. A virgin IP failing
  on its first-ever call rules out exit-IP reputation, so this is a gate, not a rate limit —
  concurrency, waiting and proxy choice do not move it. `asyncPCDetail`, the msite endpoint and
  `acs.aliexpress.us` fail identically, so the gate is the `acs` gateway rather than one API.
  Consequence: `fetchProductDetails` ships **off**, and every enrichment-only column ships `null`
  with `detailFetched: false`. **You are never charged the enrichment event for a row it did not
  enrich**, so a blocked run costs exactly the product rate.
- **There is no SSR / SEO backdoor to fall back on.** `/i/`, `m.`, `.us` and a Googlebot user-agent
  all return the same ~77 KB client-rendered shell with `window.runParams = {}` and no PDP modules,
  no `ld+json`. This was checked before concluding the fields are unavailable.
- **What would fix it, and why it is not shipped.** A real browser's product-page load still makes
  exactly this API call, so the endpoint is live — the difference is a cookie jar minted by
  executing AliExpress's own AWSC/baxia JS. Minting that in Chromium and reusing it for cheap HTTP
  calls is the open lead. It is **unverified**, so nothing in this Actor depends on it and nothing
  here promises it.
- **The search path can be challenged too.** Measured 2026-08-27, a home IP was x5sec-walled on the
  *search* path after about six page fetches and was still walled 90 s later, while Apify
  RESIDENTIAL cleared the same queries first try. The Actor detects a challenged page (short body /
  punish markers) and retries up to four times on fresh sessions, with a capped RESIDENTIAL attempt
  last — limit 12 fetches per run, and only if your Apify plan includes the RESIDENTIAL group. If it
  does not, the run logs *"RESIDENTIAL proxy unavailable — search fallback rung disabled"* and
  continues on datacenter alone. On a long run, expect some pages to be lost; the log counts them.
- **Enrichment gives up early on purpose.** If you switch it on and three pool cooldowns pass with
  zero successful detail fetches, the Actor stops trying and ships search-level rows for the rest of
  the run rather than burning your timeout (measured 2026-08-26: 5 cooldowns, 0 enriched rows,
  309 s). The log prints *"Detail enrichment abandoned after N cooldowns…"* plus `detailOk /
  detailFail`, sessions minted and retired, and a per-error-code tally, so a site-side change — in
  either direction — is visible immediately.
- **No reviews, no review text, no Q\&A.** `rating`, `reviewCount` and `sellerScores` are the
  aggregate numbers; individual customer reviews are not collected.
- **No product description HTML.** `properties` is AliExpress's structured specification table.
  The rich HTML/image description block is not parsed.
- **No order history, no price history, no sales estimates.** `ordersText`/`ordersCount` are
  AliExpress's own bucketed lifetime counter ("10,000+ sold"), so `ordersCount` is a floor —
  verified never to overstate (216 cards checked against AliExpress's own internal exact counter:
  0 overstatements). To get price or stock history, schedule the Actor and diff its runs — nothing
  historical is fetched.
- **Product URLs are not usable while the detail API is blocked**, since there is no search card
  behind them. The run logs a warning telling you so and pointing you at `searchQueries`.
- **Some search-side columns are partial by nature.** `storeName` is on 59% of cards, the three
  delivery fields on 62% (25–75% depending on the keyword), `shipsFrom` on 94%. These are the
  fill rates of AliExpress's own card data, not scrape failures, and a `null` there is not a bug.
- **A URL that is not an AliExpress product link is skipped with a warning.** Only `/item/<id>.html`,
  `/i/<id>.html`, `?productId=<id>` and bare numeric ids are recognised. Store URLs, category URLs
  and search URLs are not.
- **A search stops on the first unusable page.** Four attempts on fresh sessions, and if none
  returns a parseable page the keyword ends with a warning rather than skipping ahead. Same if the
  page returns HTTP 200 but carries no `_init_data_` blob (markup change or a soft block).
- **Pagination ceilings.** 60 products per page, `maxPagesPerQuery` capped at 200, and a run-wide
  hard cap of 100,000 products. AliExpress's advertised result totals are far larger than what it
  will actually page through.
- **No login, so no logged-in pricing.** Coupons tied to an account, app-only prices and welcome
  deals are invisible to this Actor by design.
- **`robots.txt`.** `https://www.aliexpress.com/robots.txt` (fetched 2026-08-26) has no blanket
  disallow for `User-agent: *`; it disallows paths including `/search/*`, `/api/*`, `/product/*`
  and `/productdetail/*`, and explicitly allows `/wholesale.html$` and `/wholesale-page-*.html`.
  The search path this Actor reads, `/w/wholesale-<slug>.html`, is named by neither rule — it is
  not disallowed. The product-detail API this Actor calls lives on a **different host**,
  `https://acs.aliexpress.com/robots.txt`, which reads, verbatim: `User-agent: *` /
  `Disallow: /`. This Actor reads only what a logged-out visitor's own browser loads and nothing
  behind authentication; whether and how you may use AliExpress content commercially is governed by
  AliExpress's Terms of Use, and is your responsibility.

***

### How it works, and what it cost to make reliable

**Public, logged-out, and no CAPTCHA is ever solved.**

**1. Search.** `https://www.aliexpress.com/w/wholesale-<slug>.html?g=y&SearchText=<q>` with
`&page=N`, `&SortType=`, `&minPrice=`, `&maxPrice=` appended as needed. The HTML carries
`window._dida_config_._init_data_ = { data: {…} }` — an object literal with an unquoted key, so the
Actor walks to the first `{` after `data:` and cuts a **string-aware balanced JSON object** rather
than trusting a regex. Inside are **60 product cards per page**: productId, title, sale and original
price, discount %, star rating, the "10,000+ sold" string, listing date, selling-point tags and the
ad flag — **plus each card's second data layer**, which is where this Actor differs from most:
`images[]` (the gallery), `trace.utLogMap.categoryId` (a category path whose leaf equals the
product-detail API's `categoryId`), `trace.utLogMap.delivery_info` (a `"min,max,shipDate,etaDate"`
string), `trace.pdpParams.pdp_cdi` (URL-encoded JSON carrying the card SKU's `shipFrom`) and
`trace.custom.p4pExtendParam.store_name`. Those five are parsed into `images`, `categoryId`,
`deliveryDaysMin`/`Max`/`deliveryDate`, `shipsFrom` and `storeName` with **no extra request**.
Seller identity, SKUs and stock genuinely do not exist at this level — verified by needle-sweeping
240 cards — which is what step 2 was for.

**2. Detail (optional, and currently blocked — see below).** The signed mtop API the product page itself calls:
`GET https://acs.aliexpress.com/h5/mtop.aliexpress.pdp.pc.query/1.0/`, where
`sign = md5(token & timestamp & appKey & data)` and the `_m_h5_tk` token is minted by the first
(unsigned) call's `Set-Cookie`. It returns ~27 modules — `SKU`, `PRICE`, `SHIPPING`,
`SHOP_CARD_PC`, `PC_RATING`, `QUANTITY_PC`, `PRODUCT_PROP_PC`, `WISHLIST`, images and more — which
the Actor flattens into the row. Your `shipToCountry` and `currency` are passed **into the call**,
not inferred from the exit IP.

**The wall, and what changed.** Alibaba's in-house "punish" / x5sec system guards the detail API.
On **2026-08-22** it behaved as a per-exit-IP *rate limit*: a fresh session cleared 55–70% of the
time and served ~8 rapid calls, which is why the Actor is built the way it is — every worker holds
exactly one session (its own cookie jar, UA and proxy exit), retires it after 7 successes or on the
first failure, retries the product on a fresh session, and the whole pool pauses together when it
goes hot.

**On 2026-08-27 that stopped being true.** The gate now fires *before a session can exist*: the
first, unsigned, token-minting call is refused and no `_m_h5_tk` is ever set, on a clean IP that had
never contacted the host. The rotation machinery is still in the code — it costs nothing when
enrichment is off, and it is the cheapest possible probe for the day the gate reopens — but it is no
longer a design that can be tuned into working, and this README no longer pretends otherwise.

**Transport ladder, measured 2026-08-27:**

| Rung | Search page HTML (`/w/wholesale-*.html`) | Product-detail mtop API |
|---|---|---|
| Apify **datacenter** (the default) | clean on every sample query | **0/5** — `FAIL_SYS_USER_VALIDATE` |
| Apify **RESIDENTIAL** | cleared all sample queries first try; used as a capped fallback (≤12 fetches/run) when a datacenter page comes back challenged | **0/4** — same refusal |
| Clean **home IP**, never used against the host | walled on the *search* path after ~6 fetches | **0/3** — same refusal on the first-ever call |

The bottom-right cell is the one that matters: a virgin IP refused on its very first request is a
gate, not a rate limit. The bottom-left cell is the operational one: the punish is not confined to
the detail API, so search pages need the retry ladder too.

**Other things built in because they bite in production:**

- **No browser.** Pure HTTP with `got-scraping`, so the Actor declares **512 MB** and a 1-hour
  default timeout rather than the 2–4 GB a Chromium-based scraper needs.
- **A wall-clock budget.** The Actor reads the run's own timeout, holds back a safety margin, and
  stops fetching in time to push what it has — a timeout never costs you the rows already collected.
- **A charge-headroom clamp.** Remaining `maxTotalChargeUsd` is read at startup and the crawl stops
  at the affordable product count, so a capped buyer is not charged compute for rows that could
  never be delivered.
- **Crash nets.** Any unforeseen throw — including the internal uniqueness assertion — is logged and
  turned into a clean SUCCEEDED exit carrying a status message that names the failure, rather than a
  FAILED run.
- **Retries on `pushData` itself** — three attempts. If all three fail, the batch is dropped and its
  ids are rolled back so nothing is billed for rows that never landed; the rows themselves are lost
  for that run and the log says so. Re-run to collect them.

***

### Duplicates

Within one results page AliExpress repeats nothing. Duplicates come from fan-out — the same product
matching "wireless earbuds" and "bluetooth headphones", or appearing again on a later page after the
ranking shifts mid-walk. The Actor keeps a **run-wide set keyed on `productId`**, de-duplicates
**before anything is charged**, and then **asserts uniqueness across everything it pushed** at the
end of the run — a duplicate leak stops the run with a status message naming the assertion rather
than quietly billing you twice. Every run logs the exact number of duplicates it dropped.

Dedupe is **per run**. Across scheduled runs you will see the same products again; key on
`productId` in your own store and treat repeat rows as updates.

***

### When a run stops early

This Actor never ends FAILED. A duplicate leak trips an internal uniqueness assertion, which the
crash net converts into a SUCCEEDED run whose status message reads *"Stopped early — Uniqueness
assertion failed: …"*. Check the status message, not the run status.

Every other early stop also ends **SUCCEEDED with a status message** that says exactly what
happened:

- **0 products** → the message names the cause: AliExpress returned pages with no product cards
  (no results, or a soft block — re-run in a minute), search pages could not be fetched through the
  proxy after retries, or nothing matched your queries and URLs.
- **Run time budget** → *"Stopped early to stay inside this run's time limit — N product(s)
  delivered (M with detail). Raise the run timeout or lower maxItems for the rest."*
- **Charge cap** → *"Stopped at this run's pay-per-event charge cap with N product(s) returned…
  Every row delivered was billed. Raise the run's max total charge to collect more."*
- **No budget at all** → if the run's max total charge cannot pay for even one product, the Actor
  exits immediately having fetched nothing and billed nothing, and says so.
- **Partial enrichment** → *"N product(s) returned, M with seller/SKU/shipping detail (K could not
  be enriched and shipped as search-level rows)"* — an informational status, not an error.
- **No input at all** → a bare `{}` runs the documented sample search and says so in the status,
  so a misconfigured Task returns data instead of nothing.

***

### Who buys this

> Written for the full field set. While the product-detail API is blocked, the uses that depend on
> `skus[]`, `totalStock`, `reviewCount` or the seller-identity columns are **not** currently served —
> they are marked ⚠️ below.

- **Dropshippers and product researchers** — `sortBy: "orders"` inside a price band, sponsored cards
  filtered out, then rank by `ordersCount` and `rating`. `shipsFrom` tells you in one column which
  winners can actually be delivered in under a week, and `deliveryDaysMax` puts a number on it.
- ⚠️ **Sourcing and procurement teams** — `skus[]` with per-variant price and stock is a supplier
  quote sheet: `minSkuPrice`/`maxSkuPrice` for the real cost spread, `totalStock` and `maxPerOrder`
  for whether they can fill your order, `storeOpenedAt` + `sellerPositiveRate` + `sellerScores` for
  whether you want them to. **Blocked today.**
- **Price monitors** — recipe 4 on a Schedule, diffing `salePrice`, `discountPercent` and
  `deliveryDaysMax` per `productId`. Cheap enough to run daily on hundreds of listings. (Stock
  monitoring needs `totalStock` and is ⚠️ blocked.)
- **Competitive-intelligence and market-research teams** — a whole category at 60 rows per page
  with discount depth, orders, warehouse split and `categoryId`, in your currency and your shipping
  lane rather than a US-default one.
- **Brand-protection teams** — search your own brand terms, then group by `storeName` (present on
  \~59% of cards) to see who is listing your products. Joining to store pages needs `storeId` and is
  ⚠️ blocked.
- **AI / RAG and analytics pipelines** — clean JSON with a stable `productId`, ISO `scrapedAt`,
  keyword provenance on every row, de-duplicated, no HTML to clean, and a column set that does not
  move between runs.

***

### FAQ

**Does this need an AliExpress account, a login or an API key?**
No. It reads the same public search pages and the same product-detail endpoint a logged-out browser
loads. No cookie of yours is ever used, and nothing behind a login is collected.

**Does it solve CAPTCHAs?**
Never. When AliExpress challenges a session, that session is retired and the product is retried on a
fresh one. No challenge is ever answered or bypassed.

**Will every row have the seller and SKU fields?**
**No — right now, no row will.** Those come from AliExpress's product-detail API, which as of
2026-08-27 refuses every request this Actor can make (0/5 datacenter, 0/4 residential, 0/3 clean
home IP — it rejects even the first unsigned call, so it is a gate rather than a rate limit).
`fetchProductDetails` therefore ships off and those columns ship `null` with
`detailFetched: false`. You are not charged the enrichment event for them. What you *do* get on
every row, from the search page alone: title, prices, discount, rating, orders, tags, listing date,
the image gallery, the leaf `categoryId`, `shipsFrom`, a delivery-days/date estimate, and
`storeName` on about 59% of cards.

**Should I switch the proxy to residential?**
Not to fix enrichment — residential is blocked on the detail API exactly like datacenter. It is
used automatically, and only for search pages, when a datacenter page comes back challenged —
capped at 12 fetches per run, and only if your Apify plan includes the RESIDENTIAL group. If it
does not, the run logs *"RESIDENTIAL proxy unavailable — search fallback rung disabled"* and
continues on datacenter alone. Making residential the primary is worth trying only if search pages
keep failing in your region.

**How many products can one keyword return?**
60 per page, up to `maxPagesPerQuery` pages (default 20 ≈ 1,200; maximum 200). A search ends on its
own when AliExpress runs out of results. Add keywords to go wider.

**Can I get prices in my own currency and shipping to my own country?**
Yes — set `shipToCountry` and `currency`. Both are pinned in AliExpress's locale cookie on the
search request (and passed into the detail-API call when enrichment is on), so the result should not
depend on which proxy IP served the request. Note the delivery estimate on a search-level row is the
one AliExpress renders on the card for that locale, not a checkout quote, and `shippingFee` itself
is enrichment-only and therefore currently `null`. The Actor does not verify the currency that comes back —
check `currency` on the rows if you are mixing regions.

**Do I pay for duplicates or filtered-out products?**
No. Duplicates are dropped on `productId` before billing; price-band filtering happens on
AliExpress's side before anything is fetched; sponsored rows removed by `excludeSponsored` are
dropped before billing.

**What does enrichment actually cost me?**
$0.002 per row where the detail API returned data, on top of the $0.0025 product row — $0.0045 for a
fully-detailed product. It is **off by default**, and while the API is blocked it returns data for
no rows, so you pay $0.0025 flat. Switching it on while it is blocked costs you nothing in charges;
it only costs run time, and the Actor gives up after three cooldowns.

**Can I scrape a whole store's catalogue?**
Not directly — this Actor takes keywords and product URLs, not store URLs. Search a brand or store
name as a keyword, or collect `productUrl`s and feed them in.

**Does it get customer reviews?**
No. `rating`, `reviewCount` and `sellerScores` are the aggregates; individual review text is out of
scope.

**Will a run ever succeed with zero rows?**
It will end SUCCEEDED with a status message naming the cause, so a scheduled monitor on a quiet
keyword is not flagged as broken.

***

### Legal & fair use

This Actor reads public AliExpress search results and public product pages as a logged-out visitor.
It does not log in, does not use any account, cookie or API key, does not solve or bypass any
challenge, and collects nothing behind authentication.

`https://www.aliexpress.com/robots.txt` (fetched 2026-08-26) carries no blanket disallow for
`User-agent: *`; it disallows a list of paths including `/search/*`, `/api/*`, `/product/*` and
`/productdetail/*`, and explicitly allows `/wholesale.html$` and `/wholesale-page-*.html`. The
search path this Actor reads, `/w/wholesale-<slug>.html`, is named by neither rule — it is not
disallowed. The product-detail endpoint used for enrichment is on a separate host, and
`https://acs.aliexpress.com/robots.txt` reads, verbatim: `User-agent: *` / `Disallow: /`.
AliExpress's Terms of Use govern how its content may be used.

Product listings, store names and seller identifiers are commercial business data, but rows can
still contain names and locations. **You are responsible for complying with AliExpress's terms and
with how you use the data**, including GDPR/CCPA where it applies and the rules on unsolicited
contact.

AliExpress® and Alibaba® are trademarks of their owners. This Actor is not affiliated with,
endorsed by, or connected to AliExpress or Alibaba Group.

***

### Feedback

Found a missing field or want a new filter — a category filter, a `shipsFrom` filter, store-URL
input? Open an issue on the **Issues** tab, and if the Actor earns it, a review on the **Reviews**
tab helps other buyers find it.

# Actor input Schema

## `searchQueries` (type: `array`):

Keywords to search on AliExpress, one per line — e.g. "wireless earbuds", "phone case", "led strip lights". Each keyword walks the search results 60 products per page, in the sort order chosen below, until Max products, Max products per keyword or Max pages per keyword is reached. Leave empty if you only paste product URLs.

## `productUrls` (type: `array`):

AliExpress product pages to scrape directly, e.g. https://www.aliexpress.com/item/1005012258457853.html (aliexpress.us /item/ URLs and bare numeric product ids also work). IMPORTANT: a product URL can only be filled by the product-detail API, which AliExpress is currently challenging on every request — so unless you turn "Enrich…" on below AND it happens to clear, these rows come back with just the id and URL. For dependable output, use keywords instead.

## `sortBy` (type: `string`):

How AliExpress orders the search results. "Best selling" is the dropshipping-research choice (most orders first); the two price sorts are for sourcing. Applies to keyword searches only.

## `minPrice` (type: `number`):

Only return products whose sale price is at or above this, in the currency chosen below. Applied by AliExpress itself on the search page, so filtered-out products are never fetched or billed. Leave empty for no minimum.

## `maxPrice` (type: `number`):

Only return products whose sale price is at or below this, in the currency chosen below. Applied on the search page, so filtered-out products cost nothing. Leave empty for no maximum.

## `excludeSponsored` (type: `boolean`):

AliExpress mixes paid "Ad" cards into every results page (measured: 60 of 240 cards across four sample searches). ON drops them before they are fetched or billed; OFF keeps them with isSponsored = true so you can filter later.

## `fetchProductDetails` (type: `boolean`):

OFF (the default) returns everything AliExpress puts on the search results page — title, sale and original price, discount, rating, orders, selling-point tags, ad flag, listing date, the full image gallery, category id, store name, ships-from country and the delivery-days/date estimate. ON additionally calls the product-detail API for every product to try to add store id and URL, seller id and country, store opened date, positive-feedback rate, seller scores, followers, review count, per-SKU prices and stock, total stock, shipping fee and product properties. HONEST STATUS (re-measured 2026-08-27): that API is refusing every request — including the first, unsigned one from an IP that has never contacted it — on datacenter proxies, residential proxies and clean home IPs alike. It is not a rate limit, so waiting or changing proxies does not help. Leave this OFF unless you want to probe whether it has reopened; the enrichment event is charged ONLY for rows the API actually returned data for, so a failed attempt costs you nothing but run time.

## `shipToCountry` (type: `string`):

Two-letter country code AliExpress prices and ships to (US, GB, DE, FR, ES, IT, NL, AU, CA, BR, MX …). Prices, the delivery estimate and stock depend on it. Sent to AliExpress in a locale cookie and in the detail-API call, so it is stable regardless of which proxy IP served the request.

## `currency` (type: `string`):

Three-letter currency code for every price in the output (USD, EUR, GBP, AUD, CAD, BRL, MXN …). Pinned via AliExpress's own locale cookie, so prices never leak the proxy's country. Price filters above are read in this currency.

## `maxItems` (type: `integer`):

Stop after this many unique products across all keywords and URLs. This is your hard cost ceiling: at $0.0025 per product, 60 = $0.15, 500 = $1.25, 1,000 = $2.50. If you switch detail enrichment on, add $0.002 for each row it manages to enrich (60 = $0.27, 500 = $2.25, 1,000 = $4.50 at 100% enrichment). Duplicates are dropped before billing and never count against it.

## `maxItemsPerQuery` (type: `integer`):

Cap per keyword so one broad term cannot use the whole budget. 0 = no per-keyword cap (only Max products applies). AliExpress returns 60 products per page, so 120 = two pages.

## `maxPagesPerQuery` (type: `integer`):

How deep to walk each keyword's results. 60 products per page; AliExpress advertises tens of thousands of results for common terms but a search stops on its own when a page comes back short. 20 pages ≈ 1,200 products per keyword.

## `maxConcurrency` (type: `integer`):

How many product-detail calls run in parallel when detail enrichment is on. Each worker owns one proxy session and retires it on the first challenge, retrying the product on a fresh one. Has no effect with enrichment off (the default), and raising or lowering it does not get past the current block, which is not rate-based. Values above 8 are rejected.

## `proxyConfiguration` (type: `object`):

Apify datacenter proxy (the default) is the right rung for the search pages this Actor reads: measured clean on every sample search, with an automatic capped RESIDENTIAL retry if a datacenter page comes back challenged. Switching the primary to RESIDENTIAL is worth trying only if search pages keep failing in your region — it does not help the product-detail API, which is blocked on residential, datacenter and clean home IPs alike.

## Actor input object example

```json
{
  "searchQueries": [
    "wireless earbuds"
  ],
  "productUrls": [],
  "sortBy": "default",
  "excludeSponsored": false,
  "fetchProductDetails": false,
  "shipToCountry": "US",
  "currency": "USD",
  "maxItems": 60,
  "maxItemsPerQuery": 0,
  "maxPagesPerQuery": 20,
  "maxConcurrency": 4,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per unique AliExpress product. Always filled, from the search page alone: id, URL, title, image gallery, currency, sale and original price, discount %, rating, orders text and count, selling-point tags, sponsored flag, listing date, category id, store name, ships-from country and the delivery days/date estimate. Filled only when the optional product-detail API enrichment is switched on AND clears (it is currently blocked — see the README): store id/URL, seller id and country, store opened date, positive-feedback rate, seller scores, followers, review count, every SKU with its own price and stock, total stock, in-stock flag, shipping fee and product properties. Every column is always present; unfilled ones are null, and detailFetched tells you which rows were enriched. Deduplicated run-wide on productId before billing.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchQueries": [
        "wireless earbuds"
    ],
    "maxItems": 60,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/aliexpress-products-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchQueries": ["wireless earbuds"],
    "maxItems": 60,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/aliexpress-products-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchQueries": [
    "wireless earbuds"
  ],
  "maxItems": 60,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call scrapersdelight/aliexpress-products-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/aliexpress-products-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Z2qimk6PA1pNe6Vqh/builds/rfec7r4QMGEZm9OAf/openapi.json
