<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ActorForge</title>
    <description>The latest articles on DEV Community by ActorForge (@actorforge).</description>
    <link>https://dev.to/actorforge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4045465%2F62e091dc-aa36-4221-922c-b30f76be1256.png</url>
      <title>DEV Community: ActorForge</title>
      <link>https://dev.to/actorforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/actorforge"/>
    <language>en</language>
    <item>
      <title>What AI agents actually see when they search for your Apify Actor</title>
      <dc:creator>ActorForge</dc:creator>
      <pubDate>Thu, 03 Sep 2026 07:09:02 +0000</pubDate>
      <link>https://dev.to/apify/what-ai-agents-actually-see-when-they-search-for-your-apify-actor-13mi</link>
      <guid>https://dev.to/apify/what-ai-agents-actually-see-when-they-search-for-your-apify-actor-13mi</guid>
      <description>&lt;p&gt;Apify has spent this year making the phrase "your Actor as a tool for AI agents" concrete: a hosted Model Context Protocol (MCP) server, per-event billing an agent can actually pay, even crypto payment rails that don't require an Apify account on the caller's side. The pitch writes itself: build a good Actor, describe it well, and a robot with a budget will find you.&lt;/p&gt;

&lt;p&gt;What I could not find anywhere was numbers. When an agent goes looking for a tool, what does it actually see? Does it see the same ranking a human sees in Apify Store, or something else? Is there — as I quietly hoped — a side door for new Actors that haven't accumulated users yet?&lt;/p&gt;

&lt;p&gt;I had a good reason to want that side door. Between July 29 and August 1 I published three pay-per-event (PPE) Actors under a brand-new developer account: scrapers for Wildberries products and reviews, Avito real estate listings, and Lazada reviews — big marketplaces, quiet corners of the Store. New account, zero reviews, user counts I can list from memory. A textbook cold start.&lt;/p&gt;

&lt;p&gt;So on August 6 I measured it. I connected to the Apify MCP server the same way any agent does, ran the searches an agent would run, and compared the results against the human-facing Store search from the same day. This article is the method, the numbers, and the two traps that produced convincingly wrong results before the real ones — one of them had me believing my Actors were invisible to agents entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an agent reaches your Actor
&lt;/h2&gt;

&lt;p&gt;Before the measurements, a quick map of the pipeline, because it determines what's worth optimizing.&lt;/p&gt;

&lt;p&gt;An agent (or its harness — Claude Desktop, an SDK loop, whatever) connects to &lt;code&gt;https://mcp.apify.com&lt;/code&gt;, the hosted MCP server. Transport is Streamable HTTP; auth is either OAuth or a plain API token header. The minimal client config is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"apify"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.apify.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &amp;lt;APIFY_TOKEN&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default toolset exposes, among others, three tools that matter for discovery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;search-actors&lt;/code&gt;&lt;/strong&gt; — keyword search over the Store,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch-actor-details&lt;/code&gt;&lt;/strong&gt; — the full card of one Actor: description, pricing, stats, input schema, README,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;call-actor&lt;/code&gt;&lt;/strong&gt; — runs an Actor by name with a JSON input, which is validated against your input schema at execution time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The intended flow is exactly the one you'd guess: search → fetch details → call. Alternatively, a specific Actor can be mounted as a named tool by adding &lt;code&gt;?tools=username/name&lt;/code&gt; to the server URL, in which case the MCP server reads its input schema and generates a dedicated tool from it.&lt;/p&gt;

&lt;p&gt;Who is in the searchable pool? Not everyone. The server returns only &lt;strong&gt;free and pay-per-event&lt;/strong&gt; Actors to agentic callers — rental-model Actors are excluded — and it drops Actors that fail platform safety checks. Apify's &lt;a href="https://docs.apify.com/platform/actors/publishing/monetize" rel="noopener noreferrer"&gt;monetization docs&lt;/a&gt; add two more conditions for an Actor to be usable by agentic (and crypto-paying) callers: it must run with &lt;strong&gt;limited permissions&lt;/strong&gt;, and it must &lt;strong&gt;not&lt;/strong&gt; be a Standby-mode Actor. There is no opt-in: a pay-per-event Actor with limited permissions and no Standby is in automatically. As of late July 2026, a bit over 29,000 Actors in the Store passed that bar (counted via the public Store API with the agentic-users filter). Mine are among them, which is what made the next question interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment: searching for my own Actors the way an agent would
&lt;/h2&gt;

&lt;p&gt;I wanted the rawest possible view — no SDK, no client-side magic — so I spoke JSON-RPC to the server directly with &lt;code&gt;curl&lt;/code&gt;. Three steps; the only prerequisite is your Apify API token exported as &lt;code&gt;APIFY_TOKEN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: initialize and capture the session.&lt;/strong&gt; The session ID comes back as a response header (&lt;code&gt;-D headers.txt&lt;/code&gt; below saves those), and everything after &lt;code&gt;initialize&lt;/code&gt; must carry it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; headers.txt &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://mcp.apify.com/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"^mcp-session-id:"&lt;/span&gt; headers.txt | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small thing that cost me an attempt: grep for &lt;code&gt;^mcp-session-id:&lt;/code&gt; with the anchor. Without it, the first match is the &lt;code&gt;access-control-expose-headers&lt;/code&gt; line, which merely &lt;em&gt;mentions&lt;/em&gt; &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, and you spend a confused minute sending a CORS header list as your session ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: call the search tool.&lt;/strong&gt; Responses arrive as a Server-Sent Events (SSE) stream; the payload is the last &lt;code&gt;data:&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://mcp.apify.com/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"mcp-session-id: &lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search-actors","arguments":{"keywords":"Lazada reviews","limit":10,"offset":0}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which comes back looking like this (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;message&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"# Search results:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- **Search query:** Lazada reviews&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- **Number of Actors found:** 9&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;# Actors:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;## [Lazada Review Scraper](https://apify.com/hello.datawizards/lazada-review-scraper) ..."&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: page through and extract positions.&lt;/strong&gt; &lt;code&gt;limit&lt;/code&gt; is capped at 10, so depth comes from &lt;code&gt;offset&lt;/code&gt;. With &lt;code&gt;TOKEN&lt;/code&gt; and &lt;code&gt;SESSION&lt;/code&gt; set to the values captured in step 1 (paste them in), the probe I actually ran (per query, down to position 50):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jsonrpc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search-actors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;}}})&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mcp.apify.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type: application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept: application/json, text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-session-id: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SESSION&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)][&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://apify\.com/([a-zA-Z0-9_.-]+/[a-z0-9-]+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lazada reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromkeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# dedupe, keep order
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole rig. Except my first two attempts produced garbage, and both failure modes are worth your time, because agents will hit them too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap #1: the argument is &lt;code&gt;keywords&lt;/code&gt;, and typos are silent
&lt;/h2&gt;

&lt;p&gt;My first pass called the tool with &lt;code&gt;{"search": "avito real estate", "limit": 10}&lt;/code&gt; — &lt;code&gt;search&lt;/code&gt; felt like the obvious argument name for a search tool. The server didn't complain. It returned a perfectly plausible list of ten popular Actors. So did the next query. And the next.&lt;/p&gt;

&lt;p&gt;The tell — which I only spotted the second time it happened — was that the listing was &lt;strong&gt;byte-identical for every query&lt;/strong&gt;. Google Maps scraper, TikTok scraper, Instagram scraper, Google Search scraper, Website Content Crawler… for "avito real estate", for "lazada reviews", for anything. My Actors appeared in none of them, which read exactly like "your Actors are invisible to agents."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9nb8kmab7vxv1zdgt8u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9nb8kmab7vxv1zdgt8u.png" alt="Two different queries sent with the wrong argument name return the same default listing" width="800" height="273"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two deliberately different queries, wrong argument name — one identical answer. This is what "your search never executed" looks like.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What actually happens: the tool's input schema declares no required arguments —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— so an unknown argument is silently ignored, &lt;code&gt;keywords&lt;/code&gt; falls back to &lt;code&gt;""&lt;/code&gt;, and empty keywords are documented to return the Store's default sort order, which is popularity. (To be fair, silently dropping unknown properties is stock JSON-Schema behavior, not an Apify quirk — which is exactly why it's worth a warning.) You don't get an error. You get the most-used Actors on the platform, confidently, for any question you ask.&lt;/p&gt;

&lt;p&gt;I re-verified it on the day of writing: two deliberately different queries with the wrong argument name returned identical listings, my Actors absent from both; the same queries with &lt;code&gt;keywords&lt;/code&gt; returned distinct, correct results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqxtu6shdvzx413ywa01.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqxtu6shdvzx413ywa01.png" alt="The same query with the correct keywords argument returns a distinct, real ranking" width="800" height="290"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same session, correct argument name: a real ranking, with my Actor at #4.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two lessons. When you're the one measuring: always send a control query — if two obviously different searches return the same list, your search never executed. As a tool author: this is what LLM-facing APIs are like now. A tool that silently substitutes a default for a misspelled argument will feed an agent wrong-but-plausible data, and the agent will act on it. If you build MCP tools of your own, make unknown arguments loud.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap #2: the output is Markdown — parse the links, not the prose
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search-actors&lt;/code&gt; returns Markdown, not JSON: a heading, a bullet with your query, then one section per Actor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Search results:&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Search query:**&lt;/span&gt; Lazada reviews
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Number of Actors found:**&lt;/span&gt; 9

&lt;span class="gh"&gt;# Actors:&lt;/span&gt;

&lt;span class="gu"&gt;## [Lazada Review Scraper](https://apify.com/hello.datawizards/lazada-review-scraper) (`hello.datawizards/lazada-review-scraper`)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**URL:**&lt;/span&gt; https://apify.com/hello.datawizards/lazada-review-scraper
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Description:**&lt;/span&gt; Lazada reviews Scraper Pro extracts detailed product reviews...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first parser pulled anything shaped like &lt;code&gt;owner/name&lt;/code&gt; out of that text. It came back with results like &lt;code&gt;INN/OGRN&lt;/code&gt;, &lt;code&gt;pros/cons&lt;/code&gt;, &lt;code&gt;JSON/CSV&lt;/code&gt;, &lt;code&gt;surface/rooms&lt;/code&gt; and &lt;code&gt;10/10&lt;/code&gt; — README fragments and slashed phrases from Actor descriptions, interleaved with real slugs.&lt;/p&gt;

&lt;p&gt;That's not just noise; it &lt;em&gt;shifts positions&lt;/em&gt;. With the naive regex, one of my Actors showed at #8 instead of its real #7, another at #3 instead of #2, and a third dropped out of the top 10 entirely. If I'd stopped there, I'd have published wrong numbers that were only slightly wrong — the worst kind.&lt;/p&gt;

&lt;p&gt;The reliable anchor is the canonical URL that every listing carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://apify\.com/([a-zA-Z0-9_.-]+/[a-z0-9-]+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with order-preserving dedup afterwards, because each listing block repeats the Actor's URL up to three times (title link, URL field, pricing link).&lt;/p&gt;

&lt;p&gt;One more parsing footnote: "Number of Actors found" is the page's own count, not a total. Unlike the public &lt;code&gt;GET /v2/store&lt;/code&gt; API, the MCP channel never tells you how many results exist. An agent can't ask "how crowded is this niche" — and neither could I, through this channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result: agent search is a mirror, not a side door
&lt;/h2&gt;

&lt;p&gt;With the rig fixed, here is what an agent sees for seven queries relevant to my three Actors (positions in &lt;code&gt;search-actors&lt;/code&gt; output, probed to depth 50, August 6, server 0.14.2):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;keywords&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;position&lt;/th&gt;
&lt;th&gt;which Actor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Avito real estate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Avito real estate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Avito property&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Avito real estate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Lazada reviews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lazada reviews&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Russian marketplace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wildberries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Wildberries reviews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wildberries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Wildberries products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wildberries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Wildberries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wildberries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And here is the same day's human-side Store search — the default, filters-on view a visitor gets, which I measured through the public &lt;code&gt;GET /v2/store&lt;/code&gt; API and have spot-checked by eye on the website:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query&lt;/th&gt;
&lt;th&gt;web Store search&lt;/th&gt;
&lt;th&gt;agent (&lt;code&gt;search-actors&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;avito real estate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lazada reviews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#4&lt;/td&gt;
&lt;td&gt;#4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wildberries reviews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#7&lt;/td&gt;
&lt;td&gt;#7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wildberries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#20&lt;/td&gt;
&lt;td&gt;#20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four exact matches, including the broadest term. The Actors above me were the same incumbents I compete with in the web Store, seen through a different pipe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjql40c1rn24xo67t9i4h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjql40c1rn24xo67t9i4h.png" alt="Apify Store search for " width="800" height="541"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The human side of the mirror, same day: Store search for "avito real estate" — the incumbent at #1 (20 users), my Actor at #2 (2 users).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After measuring this from the outside, I found the receipts on the inside: the MCP server is open source, and &lt;a href="https://github.com/apify/apify-mcp-server/blob/master/src/tools/actors/search_actors.ts" rel="noopener noreferrer"&gt;&lt;code&gt;search_actors.ts&lt;/code&gt;&lt;/a&gt; simply calls the same public Store search API, passing your &lt;code&gt;keywords&lt;/code&gt; through as the search string. The MCP layer adds formatting, not ranking. Apify's &lt;a href="https://docs.apify.com/platform/actors/publishing/quality-score" rel="noopener noreferrer"&gt;documentation on Store search ranking&lt;/a&gt; states it plainly: search ranking evaluates parameters similar to the Actor quality score, and the two correlate strongly in Apify Store search &lt;em&gt;and&lt;/em&gt; the MCP &lt;code&gt;search-actors&lt;/code&gt; tool.&lt;/p&gt;

&lt;p&gt;A short-lived curiosity from six days earlier: on July 30 — when only the first two of my Actors were published and my account's identity verification was still pending — the default web Store search excluded them entirely, while the agent channel already listed them at #2 and #4. For those first couple of days, agents were the only searchers who could find my Actors at all. That divergence has since converged: verification completed, the web results caught up, and both the docs and the server source describe safety filtering on the agentic side too. Ranking was identical all along; treat the episode as trivia.&lt;/p&gt;

&lt;p&gt;The strategic conclusion is the honest one, and it's the opposite of what I hoped: &lt;strong&gt;there is no separate agent-SEO game and no cold-start bypass.&lt;/strong&gt; An agent searching for "lazada reviews" sees the same #1 and #2 a human sees. Everything you do for your Store position transfers to the agent channel automatically — and nothing extra is available there. The queue is the queue.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the agent reads once it finds you
&lt;/h2&gt;

&lt;p&gt;Position gets you into the candidate set. The pick happens on the card. Here's what &lt;code&gt;fetch-actor-details&lt;/code&gt; returned for my Wildberries Actor (trimmed — the pricing and stats lines are verbatim):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Wildberries Scraper — Products &amp;amp; Reviews (&lt;span class="sb"&gt;`actorforgehq/wildberries-scraper`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Pricing:**&lt;/span&gt; This Actor is paid per event:
&lt;span class="p"&gt;  -&lt;/span&gt; &lt;span class="gs"&gt;**Actor Start**&lt;/span&gt;: Charged when the Actor starts running. Number of events
    charged depends on Actor memory (one event per GB, minimum one event).
    ($0.02 per event)
&lt;span class="p"&gt;  -&lt;/span&gt; &lt;span class="gs"&gt;**Product scraped**&lt;/span&gt;: One Wildberries product record: name, brand, seller,
    region-pinned price before and after discount... ($0.0035 per event)
&lt;span class="p"&gt;  -&lt;/span&gt; &lt;span class="gs"&gt;**Review scraped**&lt;/span&gt;: One Wildberries review: rating, text, author and date,
    newest first. ($0.002 per event)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Stats:**&lt;/span&gt; 3 total users, 2 monthly users
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Developed by:**&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;actorforgehq&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://apify.com/actorforgehq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;community&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Last modified:**&lt;/span&gt; 2026-08-06T04:17:14.001Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvn78pzzkyfdx9s0utegr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvn78pzzkyfdx9s0utegr.png" alt="The Actor's public card on Apify Store" width="800" height="541"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same card as a human sees it. The agent gets the Markdown version — same title, same prices, same user counts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three things in that card changed how I think about Actor metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your user counts are in the agent's context window.&lt;/strong&gt; "3 total users, 2 monthly users" — the agent sees my cold start as plainly as any human browsing the Store. Social proof reaches agents too: it's right there in the tool output, next to the price. If you assumed agents would judge tools purely on descriptions and schemas: no, the popularity signal ships with the card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your PPE event names and descriptions are your pricing page.&lt;/strong&gt; The event titles and descriptions I wrote in the monetization console are rendered verbatim to the agent as the explanation of what its money buys. Write them as product copy, not as internal event IDs. And mind the grammar of your titles: the Store's price line pluralizes the event title mechanically, so mine renders as "from $3.50 / 1,000 product &lt;strong&gt;scrapeds&lt;/strong&gt;" — the top Actors in my niches sidestep this by naming events as plain nouns: &lt;em&gt;Product&lt;/em&gt;, &lt;em&gt;Review&lt;/em&gt;, &lt;em&gt;Listing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqzt1ers7d3h10i8ki1n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqzt1ers7d3h10i8ki1n.png" alt="The Actor's pricing page showing " width="800" height="541"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Event titles become customer-facing copy — including the mechanically pluralized price line.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory is a pricing parameter.&lt;/strong&gt; Note the fine print on Actor Start: &lt;em&gt;one event per GB, minimum one event&lt;/em&gt;. My Actors default to 1,024 MB, so a start bills exactly one event — $0.02 on this Actor. If I raised default memory to 4 GB, every start would silently bill four events without a single price field changing. Price &lt;em&gt;increases&lt;/em&gt; on Apify come with a built-in delay before they take effect; a memory bump does not. I now treat &lt;code&gt;defaultRunOptions.memoryMbytes&lt;/code&gt; as a line item on the price list, reviewed with the same care as the prices themselves.&lt;/p&gt;
&lt;h2&gt;
  
  
  Closing the loop: letting an agent run it
&lt;/h2&gt;

&lt;p&gt;Discovery and reading are two-thirds of the story. The third tool is &lt;code&gt;call-actor&lt;/code&gt;, so I finished the session the way an agent would — by running my own Lazada Actor through the MCP server, with a budget cap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"call-actor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"actor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"actorforgehq/lazada-reviews-scraper"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"productUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://www.lazada.vn/products/x-i246452966.html"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"maxReviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"waitSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"callOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxTotalChargeUsd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;input&lt;/code&gt; is passed through as-is and validated against the Actor's input schema at execution time. &lt;code&gt;waitSecs&lt;/code&gt; is capped at 45; a longer run would be collected asynchronously via &lt;code&gt;get-actor-run&lt;/code&gt;. Mine came back inside the window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v230oDUAehwnWsJSt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCEEDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stats"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runTimeSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;9.231&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCEEDED in 9.231s. 4 items; 39 fields available."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nextStep"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Use get-dataset-items with datasetId=TEXypQcxLYA8sOVBt ..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details here earn their keep. The response enumerates the dataset's typed fields — the metadata from my dataset schema surfacing exactly where an agent needs it. And &lt;code&gt;nextStep&lt;/code&gt; literally coaches the caller through the follow-up, field projection included. &lt;code&gt;get-dataset-items&lt;/code&gt; then returned one product record and three Vietnamese reviews with &lt;code&gt;verifiedPurchase&lt;/code&gt; flags, projected to just the fields I asked for.&lt;/p&gt;

&lt;p&gt;The bill, straight from the run record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"chargedEventCounts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"apify-actor-start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"product-scraped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"review-scraped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One start ($0.01 on this Actor), one product ($0.002), three reviews (3 × $0.006) — three cents, itemized, against a five-cent cap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbwvxrqsq6dgqimmidzi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbwvxrqsq6dgqimmidzi.png" alt="The full call-actor round trip: input, run summary, and the itemized charge" width="800" height="349"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The whole promise of "your Actor as a tool for AI agents" in one round trip: found by keyword, priced per event, run with a budget, billed itemized.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Making your Actor a good tool: what the measurements say actually matters
&lt;/h2&gt;

&lt;p&gt;Given that ranking is shared with the Store and the card is what the agent reads, "my Actor as a tool for AI agents" decomposes into concrete, boring, checkable work. This is my list, each item traceable to something above.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Win the text match in the same fields humans search
&lt;/h3&gt;

&lt;p&gt;The search — both channels — runs over title, name, description, username and README. In a web-search sweep I ran in mid-July, everything above roughly position 24 matched the query in its &lt;em&gt;title&lt;/em&gt;; README-only matches started behind them. Exact phrases beat popularity: on "avito real estate scraper" that day, an Actor with 15 users outranked one with 608 because the phrase sits in its title. My Actor titles carry the platform and the data type as plain nouns ("Wildberries Scraper — Products &amp;amp; Reviews") for the same reason. It's unglamorous SEO, and the mirror means it's the agent-facing work too.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Write input schema descriptions as arguments, not labels
&lt;/h3&gt;

&lt;p&gt;The MCP server converts your input schema into the tool schema the agent reasons over — truncating each property description at 500 characters, enum lists at 2,000. That budget is your entire opportunity to explain a parameter to the caller. In my Lazada Actor, the &lt;code&gt;sort&lt;/code&gt; field doesn't say "Sort order (0–3)"; it says why the default is &lt;em&gt;newest first&lt;/em&gt;: on a product I measured, Lazada's own "relevance" order put fifty 5-star reviews on page one and pushed every 1–2-star review to the very end — an agent sampling "relevant" reviews would hand its user a positively biased dataset without knowing it. A human skims that; an agent &lt;em&gt;uses&lt;/em&gt; it to choose parameters correctly.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Fill the dataset schema — field metadata is how agents read your output
&lt;/h3&gt;

&lt;p&gt;Apify's &lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/dataset-schema" rel="noopener noreferrer"&gt;dataset schema docs&lt;/a&gt; are direct about this: agents interacting with Actors through MCP "rely on field metadata to understand the data in your dataset," and without it they "must infer field meanings from names alone, which leads to errors." Every field in my dataset schemas has a type, title, description and example — those are the "39 fields available" the &lt;code&gt;call-actor&lt;/code&gt; response advertised. And where an Actor mixes row kinds — my Wildberries and Lazada Actors emit both products and reviews — rows carry an explicit discriminator (&lt;code&gt;"type": "product" | "review"&lt;/code&gt;), so an agent, or the next Actor in a chain, doesn't have to guess which rows are which.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Replace the default example input
&lt;/h3&gt;

&lt;p&gt;A freshly created Actor's public definition ships with &lt;code&gt;exampleRunInput&lt;/code&gt; of &lt;code&gt;{"helloWorld": 123}&lt;/code&gt;. That junk is part of what agent tooling can read about your Actor. Mine now carry a realistic minimal input (a real search query, a real product URL) — a two-minute API call that removes one entire class of failed first runs.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Be runnable and billable by a machine
&lt;/h3&gt;

&lt;p&gt;The pool filter above doubles as a checklist: &lt;strong&gt;pay-per-event&lt;/strong&gt; pricing, &lt;strong&gt;limited permissions&lt;/strong&gt;, &lt;strong&gt;no Standby mode&lt;/strong&gt; — that's what makes an Actor available to agentic callers at all (rental Actors are invisible to this entire channel), and the same three conditions gate the agentic payment rails, like the x402 protocol that landed in June 2026: USDC payments per call, no Apify account on the caller's side. None of it requires opting in; it's simply the default posture of a modern Actor.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Respect the caller's budget — it's an API now
&lt;/h3&gt;

&lt;p&gt;An agent can cap a run with &lt;code&gt;maxTotalChargeUsd&lt;/code&gt; — my demo run above did. On the Actor side, &lt;code&gt;Actor.charge()&lt;/code&gt; &lt;strong&gt;does not throw&lt;/strong&gt; when that cap is reached: it reports &lt;code&gt;eventChargeLimitReached: true&lt;/code&gt; in its return value, and the SDK silently stops charging and pushing further items. The platform won't police the cap for you — Apify's &lt;a href="https://docs.apify.com/sdk/js/docs/guides/pay-per-event" rel="noopener noreferrer"&gt;pay-per-event guide&lt;/a&gt; puts the stop logic in your Actor's hands: check the charge result and end the run yourself. If your code ignores the return value, you'll keep scraping at full speed, for free, into the void. My pipeline checks it and shuts the run down cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;review-scraped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventChargeLimitReached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Charge limit reached — stopping gracefully&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stop fetching; the caller got exactly what they paid for&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Budget-capped callers are normal callers now. Design for the cap being hit on a &lt;em&gt;good&lt;/em&gt; run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The agent channel is a mirror of Store search&lt;/strong&gt; — same index, same ranking, confirmed both by measurement (positions matched one-for-one on the same day) and by the server's source code. There is no cold-start side door; your Store position &lt;em&gt;is&lt;/em&gt; your agent position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent argument defaults are the measurement hazard.&lt;/strong&gt; A wrong argument name gave me a confident, popularity-sorted answer to a question I never asked — twice, days apart. Send control queries; distrust identical results for different questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse anchors, not prose.&lt;/strong&gt; Position numbers extracted by casual regex from Markdown were off by one to two places — and in one case by enough to knock an Actor out of the visible top 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The card is the pitch.&lt;/strong&gt; Event names, event descriptions, user counts and last-modified date all land in the agent's context. Write every string as if the buyer will read it, because the buyer's agent will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory is a price.&lt;/strong&gt; One start event per GB means your default memory setting multiplies what every caller pays before the first item arrives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went looking for a side door for cold-start Actors and found a mirror instead. I'd rather know. The work that remains is the same work it always was — be findable for the phrase, be legible on the card, be honest per event — except now half the readers parse it with a token budget, and they don't skim.&lt;/p&gt;

&lt;p&gt;If you publish Actors, I'd genuinely like to compare notes: have you measured what agents see for &lt;em&gt;your&lt;/em&gt; Actors — and did you find a query where the two channels disagree by more than a couple of positions?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Core measurements: August 6, 2026, &lt;code&gt;mcp.apify.com&lt;/code&gt; server 0.14.2, probed to position 50 per query and re-verified the same day; supporting web-search sweeps July 20 – August 6, 2026. Positions drift daily — I've watched a term move three places overnight — so expect your numbers to differ. The three Actors involved: &lt;a href="https://apify.com/actorforgehq/wildberries-scraper" rel="noopener noreferrer"&gt;Wildberries Scraper&lt;/a&gt;, &lt;a href="https://apify.com/actorforgehq/avito-real-estate-scraper" rel="noopener noreferrer"&gt;Avito Real Estate Scraper&lt;/a&gt;, &lt;a href="https://apify.com/actorforgehq/lazada-reviews-scraper" rel="noopener noreferrer"&gt;Lazada Reviews Scraper&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>mcp</category>
      <category>ai</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>4 ready-to-run recipes for pulling Reddit data as JSON (no API key, no browser)</title>
      <dc:creator>ActorForge</dc:creator>
      <pubDate>Thu, 20 Aug 2026 02:29:47 +0000</pubDate>
      <link>https://dev.to/actorforge/4-ready-to-run-recipes-for-pulling-reddit-data-as-json-no-api-key-no-browser-e33</link>
      <guid>https://dev.to/actorforge/4-ready-to-run-recipes-for-pulling-reddit-data-as-json-no-api-key-no-browser-e33</guid>
      <description>&lt;p&gt;Getting structured data out of Reddit keeps getting harder: the public &lt;code&gt;.json&lt;/code&gt; endpoints were shut down in May 2026, Wayback snapshots stopped in 2025, and official API access now sits behind pre-approval. If you just need a few thousand posts or comments as clean JSON — for market research, social listening, or an AI dataset — the remaining options are mostly heavyweight browser scrapers.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/actorforgehq/reddit-data-scraper" rel="noopener noreferrer"&gt;Reddit Data Scraper&lt;/a&gt;, an Apify Actor that takes a different route: plain HTTP requests against Reddit's HTML, no headless browser, no login, no API key. That keeps runs fast and cheap, with a couple of honest trade-offs I'll list at the end.&lt;/p&gt;

&lt;p&gt;Below are the four jobs people actually use it for. Each link opens a pre-configured example you can run as-is or tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Track every mention of a keyword
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/actorforgehq/reddit-data-scraper/examples/reddit-keyword-monitoring" rel="noopener noreferrer"&gt;Reddit keyword monitoring&lt;/a&gt; — search all of Reddit for a phrase (a brand, a competitor, a technology) and export the matching posts: title, subreddit, author, score, comment count, permalink, UTC timestamp. The &lt;code&gt;time&lt;/code&gt; filter is relative (&lt;code&gt;week&lt;/code&gt;, &lt;code&gt;month&lt;/code&gt;), so a scheduled run always returns the fresh slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Export a full comment thread
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/actorforgehq/reddit-data-scraper/examples/reddit-comment-thread" rel="noopener noreferrer"&gt;Reddit comment thread export&lt;/a&gt; — feed it one post URL and get every comment with nesting depth and parent id, enough to rebuild the tree offline or run sentiment analysis on the whole discussion.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Top posts of a subreddit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/actorforgehq/reddit-data-scraper/examples/reddit-subreddit-top-posts" rel="noopener noreferrer"&gt;Subreddit top posts&lt;/a&gt; — the classic market-research pull: top posts of any subreddit for the past week, with scores, flairs, comment counts and links.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A user's public history
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/actorforgehq/reddit-data-scraper/examples/reddit-user-history" rel="noopener noreferrer"&gt;Reddit user history&lt;/a&gt; — one account's public posts and comments in a single run, NSFW filtered out by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public data only.&lt;/strong&gt; No logins, no private or quarantined communities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No CAPTCHA or JS-challenge solving — by design.&lt;/strong&gt; If Reddit blocks a request, the Actor retries within documented rate limits and then fails loudly instead of pretending the page was empty. You get an error, not silently missing rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documented rate-limit etiquette.&lt;/strong&gt; The request pacing is in the README, not hidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pricing is pay-per-result ($1.90 per 1,000 posts, $0.75 per 1,000 comments), so an empty or failed run costs nothing.&lt;/p&gt;

&lt;p&gt;If one of the recipes almost fits but not quite, open an issue on the &lt;a href="https://apify.com/actorforgehq/reddit-data-scraper" rel="noopener noreferrer"&gt;Actor page&lt;/a&gt; — input schema changes are cheap.&lt;/p&gt;

&lt;p&gt;— Oleg Naryzhnykh&lt;/p&gt;

</description>
      <category>reddit</category>
      <category>webscraping</category>
      <category>dataengineering</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Expensive Discovery, Cheap Enrichment: A Marketplace Scraper Architecture Built Around Rate Limits</title>
      <dc:creator>ActorForge</dc:creator>
      <pubDate>Fri, 24 Jul 2026 11:28:27 +0000</pubDate>
      <link>https://dev.to/actorforge/expensive-discovery-cheap-enrichment-a-marketplace-scraper-architecture-built-around-rate-limits-1k72</link>
      <guid>https://dev.to/actorforge/expensive-discovery-cheap-enrichment-a-marketplace-scraper-architecture-built-around-rate-limits-1k72</guid>
      <description>&lt;p&gt;Most marketplace scrapers are shaped like a human browsing session: open the listing page, read the cards, click into each product, read the detail page, go back, next page. Whether it's Playwright or a stack of HTTP calls, the control flow mirrors what a person would do.&lt;/p&gt;

&lt;p&gt;That shape is intuitive and it scales badly, for a reason that has nothing to do with your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement that changes the design
&lt;/h2&gt;

&lt;p&gt;Before writing a scraper, I now spend thirty minutes on one question: &lt;strong&gt;which hosts does this site's data actually come from, and which of them are rate limited?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "is the site rate limited." Which &lt;em&gt;hosts&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here is what that looked like on Wildberries, one of the largest marketplaces in Russia. Its front end is served by four distinct services. I hit each one with concurrent requests from a single datacenter IP:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;What it serves&lt;/th&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search.wb.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;search / discovery&lt;/td&gt;
&lt;td&gt;50 parallel&lt;/td&gt;
&lt;td&gt;30 × 200, &lt;strong&gt;20 × 429&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card.wb.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;product detail, price, stock&lt;/td&gt;
&lt;td&gt;30 parallel&lt;/td&gt;
&lt;td&gt;30 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feedbacks1.wb.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reviews&lt;/td&gt;
&lt;td&gt;30 parallel&lt;/td&gt;
&lt;td&gt;30 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basket-NN.wbbasket.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;attributes, media (CDN)&lt;/td&gt;
&lt;td&gt;40 parallel&lt;/td&gt;
&lt;td&gt;40 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sequentially, search was just as unfriendly: 30 requests spread over 69 seconds returned 13 × 200 and 17 × 429. A cold first request can come back 429 before you have sent anything else.&lt;/p&gt;

&lt;p&gt;Three of the four services did not throttle me at all.&lt;/p&gt;

&lt;p&gt;The limit is &lt;strong&gt;per service, and only one service is expensive.&lt;/strong&gt; That is not a Wildberries quirk; it is the normal consequence of how large marketplaces are built. Search is a ranking system running over a huge index — it is the costly thing to serve and the thing competitors scrape, so it sits behind a gateway. Product cards, reviews and static media are cache-friendly, often on a CDN, and defended lightly or not at all.&lt;/p&gt;

&lt;p&gt;Once you know that, the browsing-session shape is obviously wrong. It spends the scarce resource on work the abundant resources would have done for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: two tiers
&lt;/h2&gt;

&lt;p&gt;Split the scraper into two tiers with different economics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — discovery (scarce).&lt;/strong&gt; The only job here is to turn a query into a list of identifiers. Nothing else. Paginate, collect ids, stop. This tier is rate limited, so it is serial, backed off, and the only place where proxies are worth paying for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — enrichment (abundant).&lt;/strong&gt; Everything else: prices, stock, reviews, attributes, images. Driven entirely by the ids from tier 1, batched, parallel, usually straight from your own IP.&lt;/p&gt;

&lt;p&gt;On Wildberries this maps cleanly. Search returns 100 products per page, and each product carries two identifiers: &lt;code&gt;id&lt;/code&gt; (the SKU-level &lt;code&gt;nmId&lt;/code&gt;) and &lt;code&gt;root&lt;/code&gt; (the parent card &lt;code&gt;imtId&lt;/code&gt;). Every enrichment endpoint keys off one of those. One throttled request buys 100 items' worth of cheap work.&lt;/p&gt;

&lt;p&gt;The ratio is the whole point. If a discovery call yields 100 ids, and enrichment for those 100 ids costs you 2 batched card requests plus 100 unthrottled review requests, then &lt;strong&gt;the overwhelming majority of your traffic is on endpoints that don't fight you.&lt;/strong&gt; Getting more data no longer means hitting the wall more often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the tiers on a new target
&lt;/h2&gt;

&lt;p&gt;The recipe is short and works on any site with a JSON-backed front end:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read &lt;code&gt;robots.txt&lt;/code&gt; first.&lt;/strong&gt; This is not a formality — it is an architectural input, and it can delete an entire design before you write a line of it. I have a worked example below where it did exactly that.&lt;/li&gt;
&lt;li&gt;Open the site with devtools on the network tab, filter to XHR/Fetch. Note the distinct hostnames. Most marketplaces use between two and five.&lt;/li&gt;
&lt;li&gt;Replay each interesting request and strip cookies, tokens and headers one at a time to see what is genuinely required. You often find that nothing was.
&lt;strong&gt;But do not do this with plain &lt;code&gt;curl&lt;/code&gt; or Node's built-in &lt;code&gt;fetch&lt;/code&gt;,&lt;/strong&gt; or you will get false negatives. Some gateways fingerprint the TLS handshake and header ordering, not just the &lt;code&gt;User-Agent&lt;/code&gt;. On Wildberries' search host, &lt;code&gt;fetch()&lt;/code&gt; with default headers returns an HTML anti-bot page while &lt;code&gt;got-scraping&lt;/code&gt; returns JSON — same URL, same IP, seconds apart. Probe with a client that emulates a real browser's TLS profile (&lt;code&gt;got-scraping&lt;/code&gt;, &lt;code&gt;curl-impersonate&lt;/code&gt;), otherwise you will write off endpoints that work fine.&lt;/li&gt;
&lt;li&gt;Probe each host separately for its tolerance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the one people skip. It is about ten lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tally&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;tally&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tally&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tally&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once per host, with a small &lt;code&gt;n&lt;/code&gt;, and stop as soon as you have your answer. You are measuring someone else's infrastructure — a handful of requests tells you what you need, and there is no version of this where hammering a target is the right call. On the targets where I got a clear "no" on the first probe, I stopped there rather than escalating.&lt;/p&gt;

&lt;p&gt;Then write the numbers down. They are the input to your design, and they go stale — treat them as dated facts, not permanent truths. The last section of this post is about what happened when I re-measured mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the implementation looks like
&lt;/h2&gt;

&lt;p&gt;Once the tiers are explicit, the code gets simpler, not more complex. Discovery is a generator that yields ids:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-1257786&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// pin region-ish params for the whole run&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;discover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://search.wb.ru/exactmatch/ru/common/v4/search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`?query=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;resultset=catalog`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`&amp;amp;dest=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEST&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;curr=rub&amp;amp;spp=30&amp;amp;appType=1&amp;amp;lang=ru&amp;amp;page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchWithBackoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// This host never serves an empty page at the end of a result set — it starts&lt;/span&gt;
    &lt;span class="c1"&gt;// serving page 1 again. Terminating on "empty" alone yields silent duplicates.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;imtId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That duplicate check is not paranoia. Measured on 2026-07-18, page 60 of a &lt;code&gt;ноутбук&lt;/code&gt; search returned page 1's exact contents — full 100 products, HTTP 200, no marker. A loop that walks until it sees an empty page never terminates on this host; it just keeps re-emitting the first page and hammering the one service that is actually rate limited. Whatever your target, verify how it signals "no more results" before you trust a termination condition.&lt;/p&gt;

&lt;p&gt;The backoff belongs to this tier only, and it has to be heuristic: the 429 response on this host is an HTML body with &lt;strong&gt;no &lt;code&gt;Retry-After&lt;/code&gt; header&lt;/strong&gt;, carrying little more than &lt;code&gt;date&lt;/code&gt; and &lt;code&gt;server&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchWithBackoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 1s, 2s, 4s, 8s...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`still throttled after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; tries: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enrichment is the opposite: batched, and indifferent to backoff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enrich&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nmIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nmIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`https://card.wb.ru/cards/v4/detail?appType=1&amp;amp;curr=rub&amp;amp;dest=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEST&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;spp=30&amp;amp;nm=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Join back by id — see the failure modes below for why this matters.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;byId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;byId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hundred products, four requests, no throttling. Compare that to two hundred detail-page loads through a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually buys you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bandwidth and compute.&lt;/strong&gt; A browser fetching a product page downloads the HTML, the JS bundle, the CSS, the fonts and the images, executes the JS, and holds a gigabyte-class process open while doing it. The equivalent JSON call transfers a few kilobytes into &lt;code&gt;JSON.parse&lt;/code&gt;. As a planning estimate I use one to two orders of magnitude difference in bytes per item — I have not run a controlled benchmark, and I'd rather say that plainly than quote a number I can't defend. The direction, though, is not in doubt, and on any platform that bills by memory × time it lands directly on your margin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy spend.&lt;/strong&gt; This is the concrete one. If only discovery is throttled, only discovery needs premium IPs. Enrichment — usually the large majority of your requests — runs from wherever you like. Proxy bills tend to be the single biggest line item in a scraping operation, and this cuts the traffic that needs premium IPs to a small fraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reliability.&lt;/strong&gt; Fewer moving parts fail less often. No headless browser to crash, no selectors to break on a redesign, no JS execution to time out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four failure modes worth knowing about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Batched endpoints drop items silently.&lt;/strong&gt; Wildberries' card endpoint accepts many ids separated by &lt;code&gt;;&lt;/code&gt; and returns only the ones it feels like — a batch of 3 came back with 2, and a batch of 21 came back with 14, no error and no indication of which went missing. Unavailable or invalid ids just evaporate. Never assume &lt;code&gt;products.length === ids.length&lt;/code&gt;; join by id, and treat absences as data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpinned parameters silently corrupt your time series.&lt;/strong&gt; Wildberries takes a &lt;code&gt;dest&lt;/code&gt; (warehouse/region) parameter that affects delivery estimates and, on the product I measured, the price: on 2026-07-15 the same item came back 1,719 ₽ cheaper under one &lt;code&gt;dest&lt;/code&gt; than another, with &lt;code&gt;basic&lt;/code&gt; unchanged and the delivery estimate moving from 18 to 27. One product on one day is not a law about their pricing, but it is more than enough to justify the rule — &lt;strong&gt;pin it once for the whole run.&lt;/strong&gt; Rotate it and you will "detect" price changes that are really just geography. Every marketplace has a parameter like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fields vanish rather than going null.&lt;/strong&gt; Wildberries expresses "unavailable" by omission: reaching into &lt;code&gt;sizes[0].price.product&lt;/code&gt; assumes a shape the API never promised, and the batching behaviour above is the same habit in a different place. Find the entry that has what you need rather than indexing blindly, and treat "no value" as a valid state rather than an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response shapes drift.&lt;/strong&gt; This is the one that turns a working scraper into a silently wrong one. Validate what you parse against a schema — zod, ajv, whatever — and fail loudly. A parse error at 3am is a maintenance ticket; a scraper quietly writing &lt;code&gt;undefined&lt;/code&gt; into a client's price feed for a week is a different kind of problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And a soft block can arrive dressed as success.&lt;/strong&gt; Alongside its 429s, Wildberries' search host sometimes answers HTTP 200 with a &lt;em&gt;different shape&lt;/em&gt; — &lt;code&gt;{state, version, params, data: {products}}&lt;/code&gt; instead of &lt;code&gt;{metadata, products, total}&lt;/code&gt;, carrying unrelated content. Status code fine, JSON fine, content wrong. A parser looking for a top-level &lt;code&gt;products&lt;/code&gt; key finds nothing and reports zero results, which your pagination reads as "the result set ended" — a truncated dataset that looks like a clean run.&lt;/p&gt;

&lt;p&gt;This is why the schema check and the termination condition have to be separate concerns: &lt;strong&gt;"the response is not what I expected" must be a retryable failure, never an empty result.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement that humbled me
&lt;/h2&gt;

&lt;p&gt;Getting that distinction right moved one of my scrapers from 91.2% to 98.75% task success over 50 live runs. I was ready to call that the end of the story. It wasn't.&lt;/p&gt;

&lt;p&gt;Same workload, same single datacenter IP, no proxies, four runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Settings&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;250 ms min delay, no retries&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 × 429, 4 × substituted response&lt;/td&gt;
&lt;td&gt;473 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;500 ms min delay + 2 retries (1.5 s / 3 s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98.75%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 × substituted response&lt;/td&gt;
&lt;td&gt;471 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;same settings, later the same day&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.14%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7 × substituted, zero 429&lt;/td&gt;
&lt;td&gt;1700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;same settings, next day after ~12 h idle&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77.94%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;13 × 429, 2 × substituted; 15/50 runs empty&lt;/td&gt;
&lt;td&gt;7532 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Runs 3 and 4 refuted two conclusions I had already drawn from runs 1 and 2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"The failures are pacing artefacts, fixable with delay and retries."&lt;/strong&gt; Refuted by run 3 — identical settings, eight percentage points lower, latency already tripling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The penalty decays if you let the IP rest."&lt;/strong&gt; Refuted by run 4 — an overnight idle produced not recovery but the worst result of the four, with mass 429s back and a p50 sixteen times the fresh figure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Caveats, stated plainly: one IP, four measurements, different times of day. I cannot fully separate accumulated penalty from time-of-day load. But monotonic degradation &lt;em&gt;through&lt;/em&gt; a rest period is hard to explain any other way.&lt;/p&gt;

&lt;p&gt;What this changes about the architecture is small but important. The two-tier split is still right — three of four hosts never throttled me at any point, so IP rotation applies to a minority of requests. What changed is that &lt;strong&gt;rotation on the discovery tier moved from "optimization when you scale" to "precondition for a reliability number you can quote."&lt;/strong&gt; And a meta-lesson worth more than the architecture: once your IP is penalised, your benchmark measures the depth of your own penalty rather than the target's behaviour, and every additional run makes it worse. At that point the correct move is to stop measuring, not to keep collecting numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the pattern inverts
&lt;/h2&gt;

&lt;p&gt;The two-tier split assumes discovery is the expensive half. On Avito — Russia's dominant classifieds site, and the country's largest real-estate portal — it is the other way round, and the reason is worth walking through because it starts with &lt;code&gt;robots.txt&lt;/code&gt; rather than with a rate limit.&lt;/p&gt;

&lt;p&gt;Avito's perimeter is Qrator, and from a datacenter IP it is absolute: the front page returned 429 and the mobile JSON API returned 403, both serving the same captcha page, on the very first request. Realistic TLS and header ordering — the exact trick that turns an anti-bot page into JSON on Wildberries — &lt;strong&gt;does not help here.&lt;/strong&gt; Same firewall page either way. The binding constraint is IP reputation and geography, not request shape.&lt;/p&gt;

&lt;p&gt;But the more consequential finding was in &lt;code&gt;robots.txt&lt;/code&gt;. Under &lt;code&gt;User-agent: *&lt;/code&gt; it disallows &lt;code&gt;/api/&lt;/code&gt; — which covers the mobile JSON API that every tutorial recommends as the cheap path — along with the parameterised search patterns (&lt;code&gt;/*price=&lt;/code&gt;, &lt;code&gt;/*pmin=&lt;/code&gt;, &lt;code&gt;/*s=&lt;/code&gt; and friends) that a filter-driven scraper would depend on. That is not a grey area; it is the target publishing its rules. Both of the obvious designs were out.&lt;/p&gt;

&lt;p&gt;What the same file &lt;em&gt;does&lt;/em&gt; publish is a sitemap index. And those sitemaps download fine from a datacenter IP, straight past the firewall — because they exist precisely to be crawled. The item maps hold 50,000 URLs each with &lt;code&gt;lastmod&lt;/code&gt; timestamps; one property section alone runs to eleven files.&lt;/p&gt;

&lt;p&gt;So the tiers invert:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discovery is cheap, open and sanctioned&lt;/strong&gt; — sitemaps, no proxy, no account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrichment is the expensive half&lt;/strong&gt; — individual listing pages sit behind the firewall and need residential IPs from the right country.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built that way, discovery measured &lt;strong&gt;100% success across 50 live runs with no proxy at all&lt;/strong&gt; (zero failures, zero retries, p50 1.8 s). That number is only impressive with its asterisk attached: it measures the half of the problem that is genuinely easy, and I am reporting it as such.&lt;/p&gt;

&lt;p&gt;Two honest consequences that a tutorial would skip. First, sitemaps have no filters or sorting, so "listings under X in district Y" is not something this tier can do — you get inventory and freshness, then filter your own copy. Second, the numeric fields buyers actually want (price, area, floor) are not in a sitemap at all, and guessing them from URL slugs is a trap: a slug fragment like &lt;code&gt;556_m&lt;/code&gt; is ambiguous between 55.6 m² and 556 m². A missing field beats an invented one.&lt;/p&gt;

&lt;p&gt;One implementation detail that cost me time: those &lt;code&gt;.xml.gz&lt;/code&gt; files arrive as &lt;code&gt;content-type: application/x-gzip&lt;/code&gt; &lt;strong&gt;without&lt;/strong&gt; a &lt;code&gt;content-encoding&lt;/code&gt; header. No HTTP client will auto-decompress that. It is a file that happens to be gzipped, not a gzipped response, and you have to gunzip it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it doesn't apply at all
&lt;/h2&gt;

&lt;p&gt;Sometimes the bottleneck is neither tier. Lazada's review endpoint is a clean H5/BFF JSON API — no token, no signature, no cookies required. The problem is throughput.&lt;/p&gt;

&lt;p&gt;From a datacenter IP it sustains roughly &lt;strong&gt;one request every five minutes.&lt;/strong&gt; That is not a figure of speech: three requests spaced ~5 minutes apart came back clean, and a single request sent 8 seconds after the previous one was punished immediately. The punishment arrives as &lt;strong&gt;HTTP 200 with an HTML body&lt;/strong&gt; — a redirect script to a slider-captcha challenge — which is a nastier failure than a 403, because naive code stores it as data.&lt;/p&gt;

&lt;p&gt;Three things I expected to help, which didn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resting the IP.&lt;/strong&gt; Fifteen hours of idle bought exactly one clean request, not a burst. The budget does not accumulate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotating regional domains.&lt;/strong&gt; The flag is shared across Lazada's country domains; a punish on one carries to the next on the first request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking for more per request.&lt;/strong&gt; A widely-copied GitHub snippet suggests &lt;code&gt;pageSize=999999&lt;/code&gt; to grab everything at once. It returns &lt;code&gt;success: true&lt;/code&gt; with an empty model — the request is rejected, not fulfilled, and code that trusts it records "no reviews" for a product that has plenty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a ceiling that no architecture can lift. The endpoint's own &lt;code&gt;paging.totalPages&lt;/code&gt; is wrong by roughly a factor of three: on the product I measured it advertised 25 pages of 1,243 reviews, and data ran out on page 8. The real ceiling is &lt;code&gt;rateCount − hiddenCount&lt;/code&gt; — 1,243 minus 864 hidden, so 379 reviews, which predicted the final short page to the exact item. &lt;strong&gt;About 30% of that product's reviews are reachable at all.&lt;/strong&gt; If you are selling review data, that number belongs in your documentation rather than in a support ticket six weeks later.&lt;/p&gt;

&lt;p&gt;The tiering idea still helps here — you still want your scarce resource spent on discovery — but the scarce resource is clean IPs and solved challenges, and no amount of restructuring changes the arithmetic. One product's 379 available reviews is 8 requests, which at datacenter pace is roughly 40 minutes. That is a verdict on the transport, not a design to optimise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Measure per host, not per site. Read &lt;code&gt;robots.txt&lt;/code&gt; before you design, not after. Put the throttled work in one tier and everything else in another, then spend your proxy budget only on the first. Validate response &lt;em&gt;shape&lt;/em&gt; separately from your termination condition, because the ugliest failures arrive with a 200.&lt;/p&gt;

&lt;p&gt;And re-measure. My best number was 98.75%, my most recent is 77.94%, and the code did not change between them. The number that matters is the one you can still reproduce today.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write these up as I go, measurements and refutations included — if you have numbers on any of the open questions above, particularly how long that search-host penalty actually lasts, I'd like to compare notes.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The three Actors this architecture came out of, if you want to see the shape in production: &lt;a href="https://apify.com/actorforgehq/avito-real-estate-scraper" rel="noopener noreferrer"&gt;Avito Real Estate&lt;/a&gt; (discovery tier, no proxies), &lt;a href="https://apify.com/actorforgehq/lazada-reviews-scraper" rel="noopener noreferrer"&gt;Lazada Reviews&lt;/a&gt; (enrichment tier behind residential IPs) and &lt;a href="https://apify.com/actorforgehq/wildberries-scraper" rel="noopener noreferrer"&gt;Wildberries&lt;/a&gt; (both tiers in one).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>architecture</category>
      <category>api</category>
      <category>node</category>
    </item>
    <item>
      <title>The Wildberries API in English: How to Get Products, Prices and Reviews as JSON</title>
      <dc:creator>ActorForge</dc:creator>
      <pubDate>Fri, 24 Jul 2026 11:27:49 +0000</pubDate>
      <link>https://dev.to/actorforge/the-wildberries-api-in-english-how-to-get-products-prices-and-reviews-as-json-4h1e</link>
      <guid>https://dev.to/actorforge/the-wildberries-api-in-english-how-to-get-products-prices-and-reviews-as-json-4h1e</guid>
      <description>&lt;p&gt;Wildberries is one of the largest online marketplaces in Russia. If you build price monitoring, assortment analytics or review mining for e-commerce, sooner or later a client asks for its data — and you discover there is no official English-language API documentation to point at.&lt;/p&gt;

&lt;p&gt;The good news is that the Wildberries storefront is a JavaScript app talking to a small set of &lt;strong&gt;public JSON endpoints&lt;/strong&gt;. No API key, no request signature, no session token — anywhere. Products, prices, stock, delivery estimates, full attributes, media and reviews are all reachable with plain HTTP requests.&lt;/p&gt;

&lt;p&gt;I mapped those endpoints from a datacenter IP. The endpoint behaviour below was measured on &lt;strong&gt;2026-07-15&lt;/strong&gt; and re-verified on &lt;strong&gt;2026-07-18&lt;/strong&gt;; the reliability numbers at the end run through &lt;strong&gt;2026-07-19&lt;/strong&gt;. Where something is a guess, I say so. Where a measurement of mine turned out to be wrong, I say that too — the last section of this post exists because my own first conclusion did not survive contact with a fourth run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule zero: every price is in kopecks
&lt;/h2&gt;

&lt;p&gt;Every price field in every Wildberries response is an integer in kopecks. Divide by 100.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"basic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;202400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"logistics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"return"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cashback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;basic&lt;/code&gt; = 2,024 ₽ and &lt;code&gt;product&lt;/code&gt; = 238 ₽ for the same headphones (live response, July 2026). &lt;code&gt;basic&lt;/code&gt; is the price before discount, &lt;strong&gt;&lt;code&gt;product&lt;/code&gt; is what the customer actually pays&lt;/strong&gt;. Get this wrong and you ship a dashboard that overstates every price by 100× — and misses the discount on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Discovery: the search endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://search.wb.ru/exactmatch/ru/common/v4/search
  ?query=iphone
  &amp;amp;resultset=catalog
  &amp;amp;dest=-1257786
  &amp;amp;curr=rub
  &amp;amp;spp=30
  &amp;amp;appType=1
  &amp;amp;lang=ru
  &amp;amp;page=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns HTTP 200 with three top-level keys: &lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;. &lt;code&gt;metadata.rs&lt;/code&gt; is 100 — you get &lt;strong&gt;100 products per page&lt;/strong&gt; — and &lt;code&gt;total&lt;/code&gt; is the size of the result set.&lt;/p&gt;

&lt;p&gt;The interesting parameter is &lt;code&gt;dest&lt;/code&gt;, which I cover below. &lt;code&gt;resultset=catalog&lt;/code&gt; gets you products; &lt;code&gt;page&lt;/code&gt; paginates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination does not end — it loops
&lt;/h3&gt;

&lt;p&gt;This one is a trap, and it is the reason I am writing this section at all. &lt;strong&gt;Wildberries does not signal the end of a result set with an empty page.&lt;/strong&gt; Past the last real page, it starts serving page 1 again — a full 100 products, HTTP 200, no marker of any kind.&lt;/p&gt;

&lt;p&gt;Walking &lt;code&gt;page&lt;/code&gt; until you get an empty response therefore never terminates. You loop forever, writing duplicates, hammering the one endpoint that is actually rate-limited.&lt;/p&gt;

&lt;p&gt;Measured on the query &lt;code&gt;ноутбук&lt;/code&gt; (2026-07-18): pages 2 and 3 returned distinct results, &lt;strong&gt;page 60 returned page 1's exact contents&lt;/strong&gt; — same leading &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is three lines: remember the first &lt;code&gt;id&lt;/code&gt; from page 1 and stop when you see it again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;firstIdOfPageOne&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// wrapped — we are done&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not substitute a hardcoded page cap for this. Where the wrap happens depends on the query.&lt;/p&gt;

&lt;p&gt;Each entry in &lt;code&gt;products[]&lt;/code&gt; carries the fields you would expect — &lt;code&gt;brand&lt;/code&gt;, &lt;code&gt;brandId&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;supplier&lt;/code&gt;, &lt;code&gt;supplierId&lt;/code&gt;, &lt;code&gt;supplierRating&lt;/code&gt;, &lt;code&gt;rating&lt;/code&gt; / &lt;code&gt;reviewRating&lt;/code&gt; / &lt;code&gt;nmReviewRating&lt;/code&gt;, &lt;code&gt;feedbacks&lt;/code&gt; / &lt;code&gt;nmFeedbacks&lt;/code&gt;, &lt;code&gt;pics&lt;/code&gt;, &lt;code&gt;colors[]&lt;/code&gt;, &lt;code&gt;subjectId&lt;/code&gt;, &lt;code&gt;totalQuantity&lt;/code&gt; — plus a &lt;code&gt;sizes[]&lt;/code&gt; array where price and stock actually live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two identifiers are the thing to understand:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field in &lt;code&gt;products[]&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nmId&lt;/code&gt; — the SKU-level id&lt;/td&gt;
&lt;td&gt;product cards, the static CDN card, the public URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;root&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;imtId&lt;/code&gt; — the parent card id&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;reviews&lt;/strong&gt;, the static card's &lt;code&gt;imt_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I verified the second one against the static card: for &lt;code&gt;nmId&lt;/code&gt; 1097975275 the search result gives &lt;code&gt;root: 2227481309&lt;/code&gt;, and the CDN card for that product reports &lt;code&gt;imt_id: 2227481309&lt;/code&gt;. Same number. Miss this and you will spend an hour wondering why the reviews endpoint returns nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extracting the price
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toRub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mapProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// An item can carry several size entries; take the first one that actually has a price.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;nmId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;imtId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toRub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;// final price&lt;/span&gt;
    &lt;span class="na"&gt;basicPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toRub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// before discount&lt;/span&gt;
    &lt;span class="na"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reviewRating&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;feedbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbacks&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nmFeedbacks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inStock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalQuantity&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://www.wildberries.ru/catalog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/detail.aspx`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;.find()&lt;/code&gt; is not defensive programming for its own sake. Wildberries expresses "unavailable" by &lt;strong&gt;omission&lt;/strong&gt; rather than by a null or an error — you will see the same habit in the batching behaviour below, where sold-out ids simply vanish from a response. Reaching straight into &lt;code&gt;sizes[0].price.product&lt;/code&gt; assumes a shape the API never promised you. Treat a missing price as "not purchasable right now", not as an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Product detail: the card endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://card.wb.ru/cards/v4/detail?appType=1&amp;amp;curr=rub&amp;amp;dest=-1257786&amp;amp;spp=30&amp;amp;nm=1097975275
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is &lt;code&gt;products[]&lt;/code&gt; with &lt;strong&gt;the same product schema as search&lt;/strong&gt;, plus a &lt;code&gt;promotions&lt;/code&gt; array. If you have written a mapper for search results, it works here unchanged.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only v4 exists.&lt;/strong&gt; I checked the variants that older blog posts and GitHub projects still reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /cards/v1/detail   -&amp;gt; 404
GET /cards/v2/detail   -&amp;gt; 404
GET /cards/detail      -&amp;gt; 404
GET u-card.wb.ru/...   -&amp;gt; 404
GET /cards/v4/detail   -&amp;gt; 200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are copying a snippet from a 2024 article, this is probably why it stopped working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It batches.&lt;/strong&gt; Pass several &lt;code&gt;nmId&lt;/code&gt; values separated by &lt;code&gt;;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;amp;nm=863160612;1097975275;14671602
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request, many products. This is the single most useful thing in this whole post for anyone scraping at volume. One caveat: &lt;strong&gt;invalid or unavailable ids are silently dropped&lt;/strong&gt;. A batch of 3 returned 2 products; a batch of 21 returned 14. Never assume &lt;code&gt;response.products.length === ids.length&lt;/code&gt; — join the results back by &lt;code&gt;id&lt;/code&gt; yourself, and treat an absence as data rather than as a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Full attributes and media: the basket CDN
&lt;/h2&gt;

&lt;p&gt;Search and card give you the commercial view. The full merchandising card — description, attribute list, certificate, composition, media — lives on a static CDN with a computed hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://basket-{NN}.wbbasket.ru/vol{vol}/part{part}/{nmId}/info/ru/card.json

vol  = floor(nmId / 100000)
part = floor(nmId / 1000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;nmId&lt;/code&gt; 1097975275 that is &lt;code&gt;vol=10979&lt;/code&gt;, &lt;code&gt;part=1097975&lt;/code&gt;, served by &lt;code&gt;basket-42&lt;/code&gt;. For &lt;code&gt;nmId&lt;/code&gt; 14671602 it is &lt;code&gt;vol=146&lt;/code&gt;, &lt;code&gt;part=14671&lt;/code&gt;, served by &lt;code&gt;basket-02&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{NN}&lt;/code&gt; is assigned by a stepped lookup on &lt;code&gt;vol&lt;/code&gt; that Wildberries extends over time (known reference points: &lt;code&gt;vol=146&lt;/code&gt; → &lt;code&gt;basket-02&lt;/code&gt;, &lt;code&gt;vol=8826&lt;/code&gt; → &lt;code&gt;basket-39&lt;/code&gt;, &lt;code&gt;vol=10979&lt;/code&gt; → &lt;code&gt;basket-42&lt;/code&gt;). &lt;strong&gt;Don't hardcode that table.&lt;/strong&gt; Probe and cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hostCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// vol -&amp;gt; host number, e.g. 10979 -&amp;gt; "42"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchStaticCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nmId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nmId&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nmId&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/vol&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/part&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/info/ru/card.json`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hostCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://basket-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.wbbasket.ru&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;hostCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// shard moved — fall through and re-probe&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://basket-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.wbbasket.ru&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hostCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`no basket host found for nmId &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nmId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One probe per &lt;code&gt;vol&lt;/code&gt; bucket, then straight hits. The payload keys are &lt;code&gt;imt_id&lt;/code&gt;, &lt;code&gt;nm_id&lt;/code&gt;, &lt;code&gt;imt_name&lt;/code&gt;, &lt;code&gt;subj_name&lt;/code&gt;, &lt;code&gt;vendor_code&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;options&lt;/code&gt;, &lt;code&gt;compositions&lt;/code&gt;, &lt;code&gt;certificate&lt;/code&gt;, &lt;code&gt;colors&lt;/code&gt;, &lt;code&gt;media&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt; and a few more; &lt;code&gt;options&lt;/code&gt; is the attribute list as &lt;code&gt;{name, value}&lt;/code&gt; pairs.&lt;/p&gt;

&lt;p&gt;And again: &lt;strong&gt;&lt;code&gt;imt_id&lt;/code&gt; here equals &lt;code&gt;root&lt;/code&gt; from search.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Reviews
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://feedbacks1.wb.ru/feedbacks/v1/{imtId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;strong&gt;&lt;code&gt;imtId&lt;/code&gt;, not &lt;code&gt;nmId&lt;/code&gt;.&lt;/strong&gt; Take it from &lt;code&gt;root&lt;/code&gt; in search, or &lt;code&gt;imt_id&lt;/code&gt; in the static card.&lt;/p&gt;

&lt;p&gt;The response gives you aggregates — &lt;code&gt;valuation&lt;/code&gt;, &lt;code&gt;valuationSum&lt;/code&gt;, &lt;code&gt;valuationDistribution&lt;/code&gt;, &lt;code&gt;feedbackCount&lt;/code&gt;, &lt;code&gt;feedbackCountWithPhoto&lt;/code&gt; / &lt;code&gt;WithText&lt;/code&gt; / &lt;code&gt;WithVideo&lt;/code&gt; — and the reviews themselves in &lt;code&gt;feedbacks[]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchReviews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imtId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://feedbacks1.wb.ru/feedbacks/v1/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;imtId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`feedbacks HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbacks&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productValuation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// 1-5&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pros&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pros&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wbUserDetails&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sellerReply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pros&lt;/code&gt; and &lt;code&gt;cons&lt;/code&gt; are separate fields from &lt;code&gt;text&lt;/code&gt; and are frequently empty — for sentiment work you want all three. &lt;code&gt;answer.text&lt;/code&gt; is the seller's public reply, which is a genuinely underrated signal if you are profiling sellers rather than products.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;feedbacks2.wb.ru&lt;/code&gt; serves identical content — it's a mirror, not a shard, so don't bother splitting requests across the two for coverage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hard limit nobody mentions: one response returns at most 1000 reviews, and there is no pagination on this endpoint.&lt;/strong&gt; I checked a product reporting &lt;code&gt;feedbacks: 132597&lt;/code&gt; in search: the endpoint returned exactly 1000 entries, the most recent ones, sorted by &lt;code&gt;createdDate&lt;/code&gt; descending. The aggregates at the top of the response (&lt;code&gt;feedbackCount&lt;/code&gt;, &lt;code&gt;valuationDistribution&lt;/code&gt;) still describe &lt;em&gt;all&lt;/em&gt; reviews — so you can compute an accurate rating distribution for a product whose individual reviews you can only sample.&lt;/p&gt;

&lt;p&gt;If you are building review mining, design for that now: you are working with a recent-1000 window, not a full corpus. Anyone promising you "all reviews for any product" from this endpoint has not tested it on a product with six-figure review counts. If you ship this to someone else, say which of the two numbers you are handing them.&lt;/p&gt;

&lt;p&gt;Two more things that will save you a debugging session:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A product with no reviews returns HTTP 200 with &lt;code&gt;feedbacks: null&lt;/code&gt;&lt;/strong&gt; — not a 404, not an empty array. So does a nonexistent &lt;code&gt;imtId&lt;/code&gt;. Guard for &lt;code&gt;null&lt;/code&gt; explicitly, or every review-less product will look like a failed request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;photos&lt;/code&gt; and &lt;code&gt;video&lt;/code&gt; behave differently.&lt;/strong&gt; The &lt;code&gt;photos&lt;/code&gt; key is present &lt;em&gt;only&lt;/em&gt; on reviews that actually have photos (43 of 1000 on one product I sampled) and holds &lt;code&gt;{id, key, isBlurred, isReady}&lt;/code&gt; entries; &lt;code&gt;video&lt;/code&gt; is present on &lt;em&gt;every&lt;/em&gt; review but is &lt;code&gt;null&lt;/code&gt; for almost all of them (2 of 1000 on the same product). Check for presence and non-null, not for type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One endpoint to avoid:&lt;/strong&gt; &lt;code&gt;/feedbacks/v1/summary/full?imtId=&lt;/code&gt;. It is referenced in various places and it returned zeroes for products that demonstrably have reviews. It looks like it works, which is worse than failing. Use &lt;code&gt;/feedbacks/v1/{imtId}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worth noting what else rides along in that same response for free: &lt;code&gt;valuationDistribution&lt;/code&gt; (the 1–5 star breakdown), the three &lt;code&gt;feedbackCountWith*&lt;/code&gt; counters, and &lt;code&gt;matchingSizePercentages&lt;/code&gt; (runs small / runs large). Those are product-level aggregates that cost you no extra request.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The &lt;code&gt;dest&lt;/code&gt; parameter, and why it will bite you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dest&lt;/code&gt; is a warehouse/region code. It is not cosmetic: it changes what the API tells you about delivery and, on the product I measured, about price.&lt;/p&gt;

&lt;p&gt;On 2026-07-15, &lt;code&gt;nmId&lt;/code&gt; 1097975275 returned &lt;code&gt;product: 11589400&lt;/code&gt; with &lt;code&gt;dest=-1257786&lt;/code&gt; and &lt;code&gt;product: 11417500&lt;/code&gt; with &lt;code&gt;dest=123585487&lt;/code&gt; — the same item, &lt;strong&gt;1,719 ₽ cheaper&lt;/strong&gt;, purely from the region code. &lt;code&gt;basic&lt;/code&gt; did not change; &lt;code&gt;time2&lt;/code&gt; (the delivery estimate) went from 18 to 27.&lt;/p&gt;

&lt;p&gt;That is one product on one day, so I would not turn it into a law about how Wildberries prices everything. It is more than enough to justify the rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pin one &lt;code&gt;dest&lt;/code&gt; for an entire run.&lt;/strong&gt; If you rotate it, your price time series is comparing different things and you will "discover" price movements that are really just geography.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;-1257786&lt;/code&gt; is the Moscow default and a reasonable choice. Some values are not valid: &lt;code&gt;dest=0&lt;/code&gt; returns &lt;code&gt;products: null&lt;/code&gt;, and &lt;code&gt;-1&lt;/code&gt; and &lt;code&gt;-446488&lt;/code&gt; come back empty.&lt;/p&gt;

&lt;p&gt;Resolving a &lt;code&gt;dest&lt;/code&gt; code from coordinates (there is a &lt;code&gt;user-geo-data.wb.ru/get_geo_info&lt;/code&gt; endpoint) is something I could not test — the host did not resolve from my environment. Treat it as unverified.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Anti-bot: two independent layers
&lt;/h2&gt;

&lt;p&gt;This is where most posts about Wildberries are either vague or wrong. There are two separate mechanisms, and the one people worry about is not the one that will stop you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1 — proof-of-work (&lt;code&gt;x-pow&lt;/code&gt;), currently not enforced
&lt;/h3&gt;

&lt;p&gt;Requests to &lt;code&gt;search.wb.ru&lt;/code&gt; and &lt;code&gt;card.wb.ru&lt;/code&gt; come back from a gateway (&lt;code&gt;server: wbaas&lt;/code&gt;) with a header like &lt;code&gt;x-pow: status=invalid;challenge=8,8,1,...&lt;/code&gt;. Wildberries has &lt;a href="https://habr.com/ru/companies/wildberries/articles/1032556/" rel="noopener noreferrer"&gt;published how this works&lt;/a&gt;: adaptive difficulty, more hashes demanded from clients that look suspicious, described there in the context of their mobile app.&lt;/p&gt;

&lt;p&gt;Here is the part that matters: &lt;strong&gt;on the web JSON endpoints it was not enforced.&lt;/strong&gt; The response arrives with &lt;code&gt;status=invalid&lt;/code&gt; and the data is in it anyway. You do not need to compute anything or send a token back.&lt;/p&gt;

&lt;p&gt;I would not build on the assumption that this lasts forever. If enforcement is switched on, an API-level scraper needs either a hash solver or a headless-browser fallback. It costs nothing to leave a seam in your code for that today.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1.5 — your HTTP client is part of the fingerprint
&lt;/h3&gt;

&lt;p&gt;Before you blame the endpoint, check what you are calling it with. On 2026-07-18 I ran the exact same search URL two ways from the same machine, seconds apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node's built-in fetch, default headers → HTML anti-bot page, not JSON&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// got-scraping → HTTP 200, JSON&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;gotScraping&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;searchUrl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same URL, same IP, completely different outcome. &lt;code&gt;search.wb.ru&lt;/code&gt; fingerprints the client — TLS handshake and header ordering, not just the &lt;code&gt;User-Agent&lt;/code&gt; string, which is why setting a browser-ish UA on plain &lt;code&gt;fetch&lt;/code&gt; does not rescue it.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds: it is a very common way to conclude "the endpoint is dead" when it is fine. Use a client that emulates a real browser's TLS profile (&lt;code&gt;got-scraping&lt;/code&gt;, &lt;code&gt;curl-impersonate&lt;/code&gt;, or a real browser). Plain &lt;code&gt;curl&lt;/code&gt; and plain &lt;code&gt;fetch&lt;/code&gt; will mislead you.&lt;/p&gt;

&lt;p&gt;The other hosts (&lt;code&gt;card&lt;/code&gt;, &lt;code&gt;feedbacks&lt;/code&gt;, &lt;code&gt;basket&lt;/code&gt;) were far less picky in my tests — they sit behind a different stack. &lt;code&gt;feedbacks1&lt;/code&gt; answers with &lt;code&gt;server: Angie&lt;/code&gt;, no &lt;code&gt;x-pow&lt;/code&gt;, and CORS &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2 — rate limiting (the one that actually stops you)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Only &lt;code&gt;search.wb.ru&lt;/code&gt; is throttled, and it is aggressive.&lt;/strong&gt; From a single datacenter IP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cold first request can return &lt;strong&gt;429 immediately&lt;/strong&gt;, before you have sent anything else.&lt;/li&gt;
&lt;li&gt;30 sequential requests over 69 seconds: 13 × 200, 17 × 429.&lt;/li&gt;
&lt;li&gt;50 parallel requests at roughly 3.8 RPS: 30 × 200, 20 × 429.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 429 body is HTML, and there is &lt;strong&gt;no &lt;code&gt;Retry-After&lt;/code&gt; header&lt;/strong&gt; — the response carries little more than &lt;code&gt;date&lt;/code&gt; and &lt;code&gt;server: wbaas&lt;/code&gt;. Your backoff has to be heuristic. The upside, at least initially: it behaves as a throttle rather than an outright ban, and a retry a few seconds after a 429 returned 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And there is a quieter failure mode than 429.&lt;/strong&gt; Sometimes search answers HTTP 200 with a &lt;em&gt;different response shape&lt;/em&gt; — instead of &lt;code&gt;{metadata, products, total}&lt;/code&gt; you get &lt;code&gt;{state, version, params, data: {products: [...]}}&lt;/code&gt; carrying unrelated content. No error code, no header to tell you apart.&lt;/p&gt;

&lt;p&gt;That one is more dangerous than a 429, because a parser looking for a top-level &lt;code&gt;products&lt;/code&gt; key finds nothing, returns zero results, and your pagination loop reads that as "the result set ended". You get a silently truncated dataset that looks like a clean run. Validate the &lt;em&gt;shape&lt;/em&gt; of the response, not just the status code, and treat a shape mismatch as a retryable failure — after a backoff, the same URL returned normal data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other hosts were not throttled in my tests:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search.wb.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50 parallel&lt;/td&gt;
&lt;td&gt;30 × 200, 20 × 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card.wb.ru/v4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 parallel&lt;/td&gt;
&lt;td&gt;30 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feedbacks1.wb.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 parallel&lt;/td&gt;
&lt;td&gt;30 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basket-42.wbbasket.ru&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40 parallel&lt;/td&gt;
&lt;td&gt;40 × 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The limit is &lt;strong&gt;per service&lt;/strong&gt;, and only one service is expensive. That single fact should shape your whole design.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What four reliability runs taught me, including where I was wrong
&lt;/h2&gt;

&lt;p&gt;I want to be careful here, because this is the section where I initially drew a conclusion that later measurements destroyed.&lt;/p&gt;

&lt;p&gt;I ran the same workload — 50 runs against live endpoints, one datacenter IP, no proxies — four times, changing the pacing settings once and then leaving them alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Settings&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;250 ms min delay, no retries&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 × 429, 4 × substituted response&lt;/td&gt;
&lt;td&gt;473 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;500 ms min delay + 2 retries (1.5 s / 3 s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98.75%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 × substituted response&lt;/td&gt;
&lt;td&gt;471 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;same settings, later the same day&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.14%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7 × substituted, zero 429&lt;/td&gt;
&lt;td&gt;1700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;same settings, next day after ~12 h idle&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77.94%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;13 × 429, 2 × substituted; 15/50 runs empty; 48 retries&lt;/td&gt;
&lt;td&gt;7532 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After run 2, the obvious story was: &lt;em&gt;the failures are pacing artefacts; slow down, retry transient errors, and you get 98.75%.&lt;/em&gt; I believed that. It is also the kind of number that ends up in a README.&lt;/p&gt;

&lt;p&gt;Run 3 was the same code with the same settings a few hours later: 90.14%, and the p50 had already more than tripled. Run 4, after leaving the IP completely idle overnight, was worse again — 77.94%, with mass 429s back and a p50 sixteen times the "fresh" figure.&lt;/p&gt;

&lt;p&gt;So two of my own conclusions were wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"It's fixed by pacing and retries"&lt;/strong&gt; — refuted by run 3. Pacing and retries are necessary. They are not sufficient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The penalty decays if you let the IP rest"&lt;/strong&gt; — refuted by run 4. Roughly twelve hours of idling produced not recovery but further degradation. Whatever &lt;code&gt;search.wb.ru&lt;/code&gt; is tracking against that IP outlives a night, or the IP has landed on a reputation list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest caveats: this is one IP, four measurements, at different times of day. I cannot fully separate "accumulated penalty" from "time-of-day load". But monotonic degradation &lt;em&gt;through&lt;/em&gt; a rest period is hard to explain any other way.&lt;/p&gt;

&lt;p&gt;Three practical consequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;98.75% is a best case on a fresh IP, not a steady state.&lt;/strong&gt; Quoting it as your service level would be a lie with a spreadsheet attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotating IPs on the discovery host is a precondition, not an optimization&lt;/strong&gt; — if you want a reliability figure above 95% that holds. The other three hosts aren't throttled, so this applies to a small fraction of your traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop benchmarking from a burned IP.&lt;/strong&gt; Once your address is penalised, your success rate measures the depth of your own penalty, not the target's behaviour — and each run digs deeper. The next honest number has to come from a fresh address.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third point is the one I would most like to have known earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The architecture that falls out of the measurements is: &lt;strong&gt;expensive discovery, cheap enrichment.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;search v4&lt;/code&gt; &lt;strong&gt;only to discover ids&lt;/strong&gt; — collect &lt;code&gt;id&lt;/code&gt; (nmId) and &lt;code&gt;root&lt;/code&gt; (imtId), paginate with &lt;code&gt;page&lt;/code&gt;, 100 per page. Rate-limit yourself here, and if you need volume, this is the only place proxies are worth paying for.&lt;/li&gt;
&lt;li&gt;Enrich with &lt;code&gt;card v4&lt;/code&gt; in &lt;strong&gt;batches&lt;/strong&gt; of many &lt;code&gt;nmId&lt;/code&gt; per request. Not throttled.&lt;/li&gt;
&lt;li&gt;Fetch reviews from &lt;code&gt;feedbacks1/v1/{imtId}&lt;/code&gt; using the &lt;code&gt;root&lt;/code&gt; you already have. Not throttled.&lt;/li&gt;
&lt;li&gt;Pull attributes and media from the basket CDN. Not throttled, no gateway in front of it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing in that pipeline needs a browser. No JS rendering, no proof-of-work solving, no login. It is HTTP and &lt;code&gt;JSON.parse&lt;/code&gt;, which means it is cheap to run and boring to maintain — the two properties you actually want in a scraper. What it does &lt;em&gt;not&lt;/em&gt; make cheap is the discovery step, and that is where your budget goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't know
&lt;/h2&gt;

&lt;p&gt;In the spirit of not pretending:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The RPS threshold for 429 on a genuinely fresh, unpenalised IP. My numbers are a lower bound measured from an IP that had already been throttled.&lt;/li&gt;
&lt;li&gt;How long the penalty on &lt;code&gt;search.wb.ru&lt;/code&gt; actually lasts. I know it survives twelve hours. I don't know whether it is days, or permanent for that address.&lt;/li&gt;
&lt;li&gt;The complete &lt;code&gt;vol&lt;/code&gt; → &lt;code&gt;basket-NN&lt;/code&gt; mapping. It drifts; probing is the correct answer.&lt;/li&gt;
&lt;li&gt;How to resolve a &lt;code&gt;dest&lt;/code&gt; code from coordinates, and the full list of valid values.&lt;/li&gt;
&lt;li&gt;What the opaque &lt;code&gt;qv&lt;/code&gt; and &lt;code&gt;kcl&lt;/code&gt; tokens in &lt;code&gt;metadata&lt;/code&gt; are for. Results come back fine without them; my guess is deep pagination, unverified.&lt;/li&gt;
&lt;li&gt;What exactly triggers the substituted-200 response, and whether the boundary where pagination wraps is a fixed result-count cap or query-dependent. I only know how to detect both, not what drives them.&lt;/li&gt;
&lt;li&gt;Whether and when Wildberries starts enforcing proof-of-work on web endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have measured any of these — particularly the penalty duration, which I can no longer test cleanly from this address — I would genuinely like to compare notes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The scraper I built on top of these endpoints is now live: &lt;a href="https://apify.com/actorforgehq/wildberries-scraper" rel="noopener noreferrer"&gt;Wildberries Scraper&lt;/a&gt; on Apify — search, product cards and reviews as JSON, billed per record. The limits above are documented on its page too, night window included.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>javascript</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
