# Wildberries SKU Enrichment: Composition, Rating, Reviews (`sergeyfaraday/wildberries-sku-enrichment`) Actor

Composition, net weight, variants, rating and public reviews for Wildberries article numbers from openly served endpoints. Pet food fields included. Price and stock available via an optional antibot gateway. Fast and cheap by default; the actor itself runs no browser.

- **URL**: https://apify.com/sergeyfaraday/wildberries-sku-enrichment.md
- **Developed by:** [Sergey Faraday](https://apify.com/sergeyfaraday) (community)
- **Categories:** E-commerce
- **Stats:** 2 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Wildberries SKU Enrichment: Composition, Rating, Reviews

Enriches a list of Wildberries article numbers (nmId) with product card data, rating and public reviews. It uses only the endpoints Wildberries serves openly: the product card CDN and the public reviews API. No browser and no proxy are needed, so it is fast and cheap.

Works for any category. For pet food it also extracts species, dog size, life stage, food format and claims.

**Not collected here by default:** price and search position. Wildberries protects them with an anti-bot challenge, so they are returned as null with a reason, never as 0. You can get them from the companion *Unified Scraper Adapter* actor (module `wb_products`), or by pointing the optional `antibot` input at an antibot gateway that solves the wbaas challenge — then this actor fetches price and stock itself from `card.wb.ru`. Real stock levels are not available from either path: the number Wildberries exposes does not behave like stock.

### How to use

1. Paste article numbers or product links into `nmIds`.
2. Turn on `includeReviews` if you need review texts.
3. Give each task a `snapshotId` (for example the date). A repeat with the same id gives the same record keys; compare `provenance.contentChecksum` to see whether the values moved. The actor itself does not compare runs: a run cannot read other runs, so comparing is a step on your side.

### Input

| Field | Default | Meaning |
|---|---|---|
| `nmIds` | | article numbers or product URLs |
| `includeRating` | true | rating and review count |
| `includeReviews` | false | one row per review |
| `maxReviewsPerProduct` | 100 | newest rows kept per requested SKU, or per card group with `reviewScope: group` |
| `reviewScope` | `sku` | `sku`: each requested SKU gets its own rows. `group`: the newest rows of the card group, once per group, whatever variant they belong to |
| `reviewsSince` | | only reviews created at or after this moment. A date without a time is UTC midnight |
| `deepBasketScan` | true | check the other live CDN hosts before reporting a product as not found |
| `maxDeepScans` | 10 | deep scans per run; ids past the limit are reported as `not_returned` |
| `snapshotId` | `<actor>.<runId>` | your id for this snapshot; re-running with the same id does not create duplicates |
| `antibotGatewayUrl` | | optional base URL of an antibot gateway that solves the wbaas challenge; with `antibotApiKey`, turns on price and stock from `card.wb.ru`. Without it, price and stock are null with a reason |
| `antibotApiKey` | | bearer token for the antibot gateway; required when `antibotGatewayUrl` is set (stored as a secret) |

```json
{ "nmIds": ["7500891", "https://www.wildberries.ru/catalog/7500746/detail.aspx"], "includeReviews": true, "maxReviewsPerProduct": 50 }
```

With the antibot gateway turned on, price and stock are fetched from `card.wb.ru`:

```json
{ "nmIds": ["7500891"], "antibotGatewayUrl": "http://127.0.0.1:8787", "antibotApiKey": "secret" }
```

### Output

Every requested id ends with one outcome in `wb_cards.applied.outcomes`: a `product` row or an `object_state` row. Exceptions: with a `maxItems` below the number of ids, or when a stop ends the run first (an abort, or a stop inside the search of an id), the ids not reached have no row and are named in `notAttemptedIds` and `excluded`. A restart of a stopped run can add a `product` row after the `not_attempted` or `blocked` row of the same id: `applied.outcomes` holds the last word and an importer keeps one logical row. If both are state rows, the first stays in the dataset, `applied.staleStateIds` names the id, and the source is `partial` so a completing run can replace it.

- `product`: name, brand, seller id, variants, category, composition text, all specifications as original text, net weight and pack count with their source, rating, review count, rating distribution per variant, card creation and update dates. `status: ok` means the card exists, not that the item is on sale: sale state, price and stock are behind the anti-bot.
- `review`: id, date, rating, text, pros and cons, variant (for example "2500 g"), tags, whether the seller answered. Buyer names, ids and photos are not stored.
- `object_state`: an id without a card row, with what is known about it:

| `status` | Meaning |
|---|---|
| `not_found` | every live CDN host answered 404, and a control made after those 404s answered: the last card read in this run, read again, and one stored card on each of three bands of hosts. Nothing less is called absence |
| `not_returned` | the search did not finish (deep scan off, `maxDeepScans` reached, time limit, or the control failed): absence is not established. `data.search` says how far it went. If the control fails the error is `CDN_CONTROL_FAILED` and no further deep scans run: the source is `partial`, or `failed` when no card was read and no id was proven absent |
| `unavailable` | some hosts failed and no other host has the card. The failed hosts are named in `statusReason`. A host name that does not resolve counts as a failed host unless it is past the end of the live range: the range ends at three dead names in a row above the hosts seen alive |
| `blocked` | an access wall answered. The run stops there instead of pushing against it. A wall sent as an HTTP 404 counts, and so does an HTML page with HTTP 200 from every host of the window. HTTP 200 with a body that is not this SKU card (empty object, JSON error, the card of another id) is a failed host, with `SCHEMA_CHANGED` on `wb_cards` |
| `not_attempted` | the run stopped before this id (time limit, row limit, a wall on an earlier id). `time.observedAt` is null. A restart of the same run that reads the id adds its `product` row beside this one: the later row wins, `sources[].applied.supersededStateRows` names the rows to drop |
| `fetch_error` | our read failed. Never a statement about the product. With no response at all, also on `unavailable`, `observedAtBasis` is `request_failed`: `observedAt` is when the read gave up |

There is no staleness rule on this source: an old card stays `ok` with no `STALE`. Its age is in `time.sourceUpdatedAt` (`Last-Modified` of the card), and what counts as too old is left to you.

```json
{
  "recordType": "product",
  "platform": "wildberries",
  "sourceRecordId": "7500891",
  "canonicalUrl": "https://www.wildberries.ru/catalog/7500891/detail.aspx",
  "status": "ok",
  "metrics": {
    "rating": { "value": 4.9, "unit": "stars_1_5", "kind": "rating", "scope": "group" },
    "reviewCount": { "value": 174, "unit": "count", "kind": "counter", "scope": "group" },
    "ratingVariant": { "value": 4.87, "unit": "stars_1_5", "kind": "rating", "scope": "sku" },
    "price": { "value": null, "unit": "RUB", "nullReason": "behind_wb_antibot_use_unified_scraper_adapter_wb_products" }
  },
  "data": { "name": "...", "brand": "...", "sellerId": "...", "compositionText": "...", "netWeightG": 2500, "packCount": 1, "weightSource": "option_net", "weightSourceText": "...", "grossWeightG": null, "species": "dog", "speciesEvidence": ["..."], "ratingByVariant": [] }
}
```

#### Time

`observedAt` is when the response of the file behind the row arrived: `card.json` for a product row, the reviews file for a review row. `sourceUpdatedAt` of a product row is the `Last-Modified` of its `card.json` (`sourceUpdatedAtBasis` says so), and `data.ratingSourceUpdatedAt` is the same for the reviews file. `collectedAt` is when this actor built the row, so "what did we know at time T" uses `collectedAt`. `runStartedAt` is the platform start, the same on every row of a run. On a measured row `runStartedAt <= observedAt <= collectedAt`.

#### Net weight

`netWeightG` is filled only when the card proves it: from a net weight option, from the weight of one piece times the count, or from the title. The result names its origin in `weightSource`, `weightSourceField` and `weightSourceText`. A candidate must agree with the declared weight with packaging: `grossWeightG` only tests it and never stands in for it. When nothing proves a net weight, `netWeightG` is null and `weightNullReason` says why, also `title_weight_not_proven_on_this_subject` (a title number with no gross weight to check it, on a subject that is not food); what was refused is in `weightRejected` (a phone title with "4G" is not 4 grams, a seller who typed kilograms into a gram field is not 0.5 grams), and its `reason` can be `technical_token`, `not_a_net_weight` or `implausible_vs_gross`.

#### Ratings

Nobody rated: `rating` is null with `no_ratings`, and the counts stay 0. A SKU missing from a filled distribution stays null with `absent_from_distribution`, because that does not prove it has no ratings. `error`, `blocked` and `not_requested` say why a rating was not read.

#### Manifest

The `MANIFEST` record in the key-value store reports the status of every source.

- `wb_cards.applied`: `outcomes` per id, `notFoundIds`, `notReturnedIds`, `unavailableIds`, `blockedIds`, `notAttemptedIds`, `deepScans`, `deepScanRequests`, `hostsLive`, `hostFailures`, `cdnControl`.
- `wb_feedbacks.applied.perProduct`: one line per requested SKU with `seen`, `olderThanSince`, `eligibleSince`, `returned`, `cutByLimit`, `cutByBudget`, `oldestSeenAt`, `newestSeenAt`, `windowCovered`, `emptyWindow`, `orderObserved`, `rowsWithoutDate`, `rowsWithoutId`, `status` and `reason`. `availableTotal`, `withText` and `returnedByEndpoint` are figures of the whole card group.

| `perProduct` status | Meaning |
|---|---|
| `ok` | the rows you asked for were delivered |
| `no_reviews` | the endpoint answered and none of the rows it served is on this SKU |
| `empty_window` | it has rows, all older than `reviewsSince`, and `windowCovered` is true |
| `duplicate_group` | `reviewScope: group`: the rows of this group are delivered under another requested SKU |
| `cut` | fewer rows than exist, or rows with no readable date under a window: `reason` names the limit, the run budget or the endpoint |
| `not_read` | no card, so no review file was read for this id. An id with no card is not looked up in the file read for its sibling |
| `error` | the reviews could not be read |

The source is `truncated` only when the endpoint could not fill the request: the window reaches past the oldest served row, or a SKU got fewer rows than the limit while the group is not served in full. A cut by your own `maxReviewsPerProduct` is shown per SKU and is not a truncation.

`wb_feedbacks` is `partial` when a requested SKU has no read card: `applied.notReadIds` and `truncationReason` name them, and a completing run adds their rows. A restart reads again a group that failed.

#### Changed source shapes

When a field the source always sends is absent or unreadable on every row at any count, or on 10% or more of the rows from 10 rows up, the source gets `SCHEMA_CHANGED` and is `partial`, or `failed` when nothing was read.

- Cards: `nm_id`, `imt_id`, `imt_name`, `subj_name`, `subj_root_name`, `selling`, `data`, and `options` when `grouped_options` is still there, when a list of name and value pairs sits under an unknown key, when the list is out of shape, or when all of 3 or more cards lack it (real thin cards have no options, so one such card raises nothing).
- Feedback files with a count above 0: `valuation`, `valuationDistribution`, `nmValuationDistribution`, `feedbackCountWithText`; a missing or broken `feedbacks` list gives line status `error`, never `no_reviews`.
- Review rows: `id`, `nmId`, `createdDate`, `updatedDate`, `productValuation`, `text`, `pros`, `cons`, `color`, `votes`, `answer`, `excludedFromRating`.

`applied.fieldChecks` holds the counts. A value that is there and unreadable is null on the row, with `data.unreadableFields: [{ field, raw }]`; a metric has `nullReason: unparseable` and keeps `raw`.

#### Personal data

Seller legal options (tax number, registration number, the seller name and address, and any option whose value names a sole trader) are removed from `data.specs` and `raw`. Country of origin, manufacturer name and address, brand and the numeric `sellerId` stay. Free text such as the description is not rewritten.

### Limits

- The reviews endpoint serves one list per card group, for all its variants, newest first by creation date (no inversion in 1733 adjacent pairs on 7 groups). It serves about 1000 newest rows per group at most: 1000 of 1440 and 1000 of 2255 rows with text on two large groups checked. An older window cannot be read here, and `windowCovered: false` says so.
- `reviewsSince` compares creation times and is inclusive to the millisecond, in UTC. An edit of an old review does not bring it back into the window.
- A review row with no readable creation date has `publishedAt` null. Under `reviewsSince` it is not delivered, `windowCovered` is false, and the line counts it in `rowsWithoutDate`. A row with no usable id is skipped and counted in `rowsWithoutId`.
- Under `reviewScope: group`, `data.productId` names a requested id whose card was read.
- A rating that could not be read stays null on its product row in that snapshot: a completing run adds review rows, it does not change a stored product row.
- Rows are text, pros and cons, or only tags: 45 of 140 rows in one check had tags and a rating but no text. Ratings with neither exist only in the star distribution.
- `reviewCountVariant` counts the ratings in the distribution, so it can be lower than the reviews of that SKU: on one group 5518 were counted and 5482 were in the distribution. `data.excludedFromRatingCount` holds the difference.
- `maxItems` is a hard cap on rows. Every id gets its row before any review does, so reviews take what is left. With a cap below the number of ids, the ids past it get no row: they are in `notAttemptedIds` and `excluded`.
- The CDN host table is measured, not published. 65 hosts resolved on 2026-11-02. A card found outside the expected window adds a `BASKET_TABLE_DRIFT` note.
- Pet food fields are hints with evidence taken from the product text, not a final classification.

### FAQ

**Why is the price null?** Wildberries protects the price with an anti-bot challenge. This actor does not try to pass it and says so with a reason on each row.

**Can I get the price and stock back?** Yes, with the optional `antibotGatewayUrl` and `antibotApiKey` inputs. Point them at an antibot gateway that solves the wbaas JS challenge and the actor fetches price and stock from `card.wb.ru` through it, retrying once on a `blocked` outcome. The gateway is a separate service you run yourself; without it, price and stock stay null with a reason. When the gateway call itself fails the row keeps the original `blocked` outcome and a `provenance.antibot` note.

**Is the rating of the SKU or of the whole card?** `rating` and `reviewCount` cover the whole card group, all variants together, as Wildberries shows them (`scope: group`). `ratingVariant` and `reviewCountVariant` are this SKU alone (`scope: sku`).

**Why did I get reviews of a variant I did not ask for?** Only with `reviewScope: group`. Those rows have `data.isRequestedSku: false` and `data.imtId` names the group. With the default `sku` every row belongs to a requested SKU. The rows do not depend on the order of `nmIds`.

**Does `status: ok` mean the product is on sale?** No. It means the card exists. Very old cards are still served: one created in 2013 answered on 2026-11-02. `data.cardCreatedAt` and `data.cardUpdatedAt` show their age.

**Why `not_returned` and not `not_found`?** A real card on another host gets the same 404 as a missing one, so only a complete search under a working CDN can say a card is absent.

### Cost

About $0.001 of platform usage for 7 products with 87 reviews, in 10 seconds. A deep scan on a missing id adds up to about 55 requests to the 10 hosts of the window, and 3 or 4 more for the control after its 404s (3 more before the first scan when no card was read yet). That bound is for hosts that answer 404: the default limit of 10 scans is at most about 700 requests, by the measured 0.32 s and $0.00004 per request about 4 minutes and $0.028. A failing host costs 4 requests and 5.6 s of waits. A scan with no 404 from any host costs 65 x 4 = 260 requests, and after it no further scan runs in that run: each next missing id asks only its window, 10 x 4 = 40 requests. Ids found in the window cost no scan.

# Actor input Schema

## `nmIds` (type: `array`):

Article numbers or product URLs. Each id ends with exactly one outcome: a product row, or an object_state row saying what is known (not_found, not_returned, unavailable, blocked, not_attempted).

## `includeRating` (type: `boolean`):

Fetch rating and review count from the public feedback endpoint (one request per card group).

## `includeReviews` (type: `boolean`):

Emit review rows (no buyer names). The public endpoint serves a subset, about 1000 newest rows per card group at most; the manifest reports per requested SKU what was seen and returned.

## `maxReviewsPerProduct` (type: `integer`):

Newest rows kept per requested SKU, or per card group with review scope "group".

## `reviewScope` (type: `string`):

Wildberries serves one review list per card group, for all its variants. "sku" delivers each requested SKU its own rows. "group" delivers the newest rows of the group once, whatever variant they belong to.

## `reviewsSince` (type: `string`):

Incremental mode: skip reviews created before this moment. The boundary is inclusive and is compared with the review creation time. A date without a time is UTC midnight.

## `deepBasketScan` (type: `boolean`):

When a card is not in the window of CDN hosts around the expected one, check the other live hosts before reporting it. Without a full scan a missing card is reported as not_returned, never as not_found.

## `maxDeepScans` (type: `integer`):

A deep scan costs up to about 55 extra requests per missing id. Ids past this limit are reported as not_returned.

## `antibotGatewayUrl` (type: `string`):

Optional. Base URL of an antibot gateway that solves the wbaas JS challenge. When set together with the gateway API key, price and stock are fetched from card.wb.ru through the gateway. Without it, price and stock stay null with a reason (the open CDN does not serve them). The gateway is a separate service, not part of this actor.

## `antibotApiKey` (type: `string`):

Bearer token for the antibot gateway. Required when a gateway URL is set.

## `snapshotId` (type: `string`):

Your id for this snapshot, one per task of one actor. Rows get recordKey = platform:type:id:snapshotId, so a repeat with the same snapshotId gives the same keys, and a new id gives a new observation. A repeat reads the source again: compare provenance.contentChecksum to see whether the values moved. Default: <actor>.<runId>.

## `queryId` (type: `string`):

Your search profile id, copied into provenance.queryId.

## `queryVersion` (type: `string`):

Search profile version, copied into provenance.queryVersion.

## `panelId` (type: `string`):

Fixed panel id for refresh runs, copied into provenance.panelId.

## `panelVersion` (type: `string`):

Panel composition version; a new composition must get a new version.

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

Hard cap on dataset rows for this run. Separate from the run money cap (maxTotalChargeUsd), which is set on the run itself.

## `maxRunSeconds` (type: `integer`):

Stop starting new units this many seconds after the run started and finish with a partial manifest. The budget belongs to the run: a restart does not renew it, and the time between workers counts.

## `keepRaw` (type: `boolean`):

Store the raw upstream item in `raw`. Off by default. provenance.rawChecksum is present on rows built from a raw item; it is null on a state row with no answer behind it and on derived rows.

## `failOnPartial` (type: `boolean`):

By default a partial result finishes as SUCCEEDED with status=partial in the MANIFEST record. Failed and blocked always finish as FAILED.

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

Proxy for upstream requests. Not needed for the open CDN in tests; keep off unless WB rate-limits the datacenter IP.

## Actor input object example

```json
{
  "nmIds": [
    "7500891",
    "7500746"
  ],
  "includeRating": true,
  "includeReviews": false,
  "maxReviewsPerProduct": 100,
  "reviewScope": "sku",
  "deepBasketScan": true,
  "maxDeepScans": 10,
  "keepRaw": false,
  "failOnPartial": false,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `manifest` (type: `string`):

No description

# 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 = {
    "nmIds": [
        "7500891",
        "7500746"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("sergeyfaraday/wildberries-sku-enrichment").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 = {
    "nmIds": [
        "7500891",
        "7500746",
    ],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("sergeyfaraday/wildberries-sku-enrichment").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 '{
  "nmIds": [
    "7500891",
    "7500746"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call sergeyfaraday/wildberries-sku-enrichment --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,sergeyfaraday/wildberries-sku-enrichment"
        }
    }
}
```

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/fqQ38EIMFQJDoPxx4/builds/FfJI814i9Uq9i2BIn/openapi.json
