Wildberries SKU Enrichment: Composition, Rating, Reviews avatar

Wildberries SKU Enrichment: Composition, Rating, Reviews

Pricing

Pay per usage

Go to Apify Store
Wildberries SKU Enrichment: Composition, Rating, Reviews

Wildberries SKU Enrichment: Composition, Rating, Reviews

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.

Pricing

Pay per usage

Rating

0.0

(0)

Developer

Sergey Faraday

Sergey Faraday

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

0

Monthly active users

16 minutes ago

Last modified

Categories

Share

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

FieldDefaultMeaning
nmIdsarticle numbers or product URLs
includeRatingtruerating and review count
includeReviewsfalseone row per review
maxReviewsPerProduct100newest rows kept per requested SKU, or per card group with reviewScope: group
reviewScopeskusku: each requested SKU gets its own rows. group: the newest rows of the card group, once per group, whatever variant they belong to
reviewsSinceonly reviews created at or after this moment. A date without a time is UTC midnight
deepBasketScantruecheck the other live CDN hosts before reporting a product as not found
maxDeepScans10deep 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
antibotGatewayUrloptional 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
antibotApiKeybearer token for the antibot gateway; required when antibotGatewayUrl is set (stored as a secret)
{ "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:

{ "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:
statusMeaning
not_foundevery 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_returnedthe 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
unavailablesome 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
blockedan 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_attemptedthe 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_errorour 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.

{
"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 statusMeaning
okthe rows you asked for were delivered
no_reviewsthe endpoint answered and none of the rows it served is on this SKU
empty_windowit has rows, all older than reviewsSince, and windowCovered is true
duplicate_groupreviewScope: group: the rows of this group are delivered under another requested SKU
cutfewer rows than exist, or rows with no readable date under a window: reason names the limit, the run budget or the endpoint
not_readno 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
errorthe 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.