<?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: Sami</title>
    <description>The latest articles on DEV Community by Sami (@sami_8858131362756585e4f4).</description>
    <link>https://dev.to/sami_8858131362756585e4f4</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%2F3877584%2F63d2c24c-ec4e-457f-8a71-2b79bb969554.png</url>
      <title>DEV Community: Sami</title>
      <link>https://dev.to/sami_8858131362756585e4f4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sami_8858131362756585e4f4"/>
    <language>en</language>
    <item>
      <title>A fully booked restaurant returns nothing. So the absence is the signal.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 14:42:24 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/a-fully-booked-restaurant-returns-nothing-so-the-absence-is-the-signal-6c3</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/a-fully-booked-restaurant-returns-nothing-so-the-absence-is-the-signal-6c3</guid>
      <description>&lt;p&gt;Here is the problem that makes restaurant availability different from every price-scraping job: &lt;strong&gt;a fully booked restaurant returns nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not an error. Not &lt;code&gt;available: false&lt;/code&gt;. An empty slot array, or the venue simply missing from the response. The most valuable state — &lt;em&gt;sold out&lt;/em&gt; — has no row to scrape.&lt;/p&gt;

&lt;p&gt;Which means you cannot detect it by parsing. You can only detect it by &lt;strong&gt;comparing against what you stored last time&lt;/strong&gt;. The absence &lt;em&gt;is&lt;/em&gt; the signal, and a stateless scraper is structurally blind to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit of state is a cell, not a venue
&lt;/h2&gt;

&lt;p&gt;Availability is a function of three inputs, and they are independent:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;venue_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;party_size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A table for 2 on Friday says nothing about a table for 6 on Friday. Collapse party size and you have thrown away the axis that matters most to anyone tracking demand.&lt;/p&gt;

&lt;p&gt;Then the comparison writes itself:&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;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;now&lt;/span&gt;  &lt;span class="o"&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;slots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;prime_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slots&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;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                              &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sold_out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# only knowable by diff
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reopened&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# a cancellation wave
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="n"&gt;NOISE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                         &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reopened&lt;/code&gt; is the one people miss. A venue going from 0 back to 4 slots is a cancellation event — for a demand analyst that is a stronger signal than the original sell-out, because it is rarer and more actionable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The noise floor is not optional
&lt;/h2&gt;

&lt;p&gt;Slot counts jitter by ±1 constantly — a hold expires, someone abandons a checkout. Without a threshold, your "changes" feed is mostly noise, and if you bill per change you are &lt;strong&gt;billing for jitter&lt;/strong&gt;. That is the fastest route to a one-star review.&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;NOISE_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;# ±1 slot is not an event
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the floor from the data, not from taste: sample one venue every minute for an hour and look at the distribution of deltas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prime time is the real metric
&lt;/h2&gt;

&lt;p&gt;"14 slots available" is a weak number. Fourteen slots at 17:15 and 22:30 is an empty restaurant; two slots at 19:30 is a busy one.&lt;/p&gt;

&lt;p&gt;So count the window separately:&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;PRIME_START_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;      &lt;span class="c1"&gt;# 18:30
&lt;/span&gt;&lt;span class="n"&gt;PRIME_END_MIN&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;      &lt;span class="c1"&gt;# 20:59
&lt;/span&gt;
&lt;span class="n"&gt;prime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;PRIME_START_MIN&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;PRIME_END_MIN&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;prime_sold_out&lt;/code&gt; while total slots stay non-zero is the scarcity signal — the restaurant is full when it matters and empty when it does not. That single derived flag is worth more than the raw count it comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting in without a login
&lt;/h2&gt;

&lt;p&gt;The public booking API is reachable anonymously with a client key that the web app ships in its own JavaScript bundle. The robust way to find it is not to hardcode it but to harvest it at runtime — and the detail that bit me is that the script &lt;code&gt;src&lt;/code&gt; attributes are &lt;strong&gt;relative&lt;/strong&gt;:&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;s&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;srcs&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.js&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resy.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort the main app bundle first and you find the key on the first fetch instead of the tenth. Keep a known-good fallback, and re-extract once mid-run if you start getting &lt;code&gt;419&lt;/code&gt; — that status means the key rotated, not that you are blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Packaged as &lt;strong&gt;&lt;a href="https://apify.com/zhorex/resy-availability-monitor" rel="noopener noreferrer"&gt;Resy Availability &amp;amp; Scarcity Monitor&lt;/a&gt;&lt;/strong&gt;: forward-dated availability by date and party size, prime-window counts, first and last seating, seating types, largest table, sold-out and prime-sold-out flags. Read-only — it books nothing, needs no account, and touches no personal data.&lt;/p&gt;

&lt;p&gt;Scheduled config ready to clone: &lt;strong&gt;&lt;a href="https://apify.com/zhorex/resy-availability-monitor/examples/nyc-restaurant-availability-changes" rel="noopener noreferrer"&gt;New York restaurant availability changes — daily delta&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The five patterns behind the delta engine are in &lt;a href="https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e"&gt;&lt;em&gt;Scraping a price is easy. Knowing it changed is the product.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tracking availability on a surface where absence is the signal? Reply with the source — the cell key is usually the hard part, and it is worth getting right before you write a parser.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The same tyre appeared twice in one response. My row count was 10% too high.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 14:41:58 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/the-same-tyre-appeared-twice-in-one-response-my-row-count-was-10-too-high-3fhg</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/the-same-tyre-appeared-twice-in-one-response-my-row-count-was-10-too-high-3fhg</guid>
      <description>&lt;p&gt;I was counting rows on a UK tyre retailer and the numbers were slightly too high. Not wildly — about 10% too many. That is the worst kind of wrong, because it looks plausible.&lt;/p&gt;

&lt;p&gt;The cause was one product appearing twice in the same response:&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;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MICH-2055516-91V"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"index"&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;"promoted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MICH-2055516-91V"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"promoted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Promoted placements are injected at the top of the result set &lt;strong&gt;and left at their natural position&lt;/strong&gt;. Same SKU, two indices. Emit both and you have inflated the row count — and if you bill per row, you have charged for a duplicate the buyer can spot in five seconds.&lt;/p&gt;

&lt;p&gt;Dedupe by SKU, keeping the natural position rather than the paid slot:&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;by_sku&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="nb"&gt;str&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="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;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by_sku&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="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&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;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;by_sku&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&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="n"&gt;hit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;em&gt;lowest&lt;/em&gt; index and you preserve organic ranking, which is a signal in its own right: it answers whether a tyre got cheaper or merely got promoted.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 406 I caused myself
&lt;/h2&gt;

&lt;p&gt;The sitemap fetch kept returning &lt;code&gt;406 Not Acceptable&lt;/code&gt;. I spent longer than I should have suspecting rate limits before reading my own headers: I was sending &lt;code&gt;Accept: text/html&lt;/code&gt; at an XML resource.&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;headers&lt;/span&gt; &lt;span class="o"&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&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;application/xml,text/xml,*/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth internalising the general shape — &lt;strong&gt;a 406 is almost never the server being broken, it is your &lt;code&gt;Accept&lt;/code&gt; header disagreeing with what the resource can produce.&lt;/strong&gt; Unlike a 403, it is entirely your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two axes, not one
&lt;/h2&gt;

&lt;p&gt;A tyre price is not a single number, it is a function of two inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;size&lt;/strong&gt; — &lt;code&gt;205/55R16&lt;/code&gt;, &lt;code&gt;195/65R15&lt;/code&gt;. This is what the buyer searches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;postcode&lt;/strong&gt; — fitting availability and price vary by location, because the fitter network does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the state key is the pair, not the product:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;postcode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Collapse the postcode axis and you get a national "average price" that describes no actual transaction. Keep it, and you can answer the question a distributor actually asks: &lt;em&gt;where&lt;/em&gt; am I being undercut?&lt;/p&gt;

&lt;h2&gt;
  
  
  What the analytics payload gives you free
&lt;/h2&gt;

&lt;p&gt;This retailer, like many, fires GA4 e-commerce events with the catalogue data already structured — &lt;code&gt;item_category&lt;/code&gt;, &lt;code&gt;item_category2&lt;/code&gt;, brand, price. Mapping those into named columns costs nothing and produces a cleaner taxonomy than parsing the page:&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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brand&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="n"&gt;hit&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vehicleType&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="n"&gt;hit&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# car / van / 4x4
&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;season&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="n"&gt;hit&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_category2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# summer / winter / all-season
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Season matters more than it looks: a winter-tyre price move in September is a different event from the same move in February.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the diff is the product
&lt;/h2&gt;

&lt;p&gt;Tyre prices move constantly — promotions, stock, competitor matching. A daily full snapshot is mostly yesterday's data re-billed. What a pricing analyst acts on is:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_move&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delisted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                 &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;   &lt;span class="c1"&gt;# not emitted, not billed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rank the lifecycle change (new / delisted) above the numeric one — an alerting rule cares far more that a size stopped being stocked than that it moved 40p. And persist the watermark &lt;em&gt;before&lt;/em&gt; charging, or a crash makes the next run re-bill changes it already sold you.&lt;/p&gt;

&lt;p&gt;A first run has no baseline, so everything looks new; billing that as "changes" is a bug, not a pricing choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Packaged as &lt;strong&gt;&lt;a href="https://apify.com/zhorex/tyre-price-monitor" rel="noopener noreferrer"&gt;UK Tyre Price &amp;amp; Fitment Monitor&lt;/a&gt;&lt;/strong&gt; — prices by size and postcode, brand and season taxonomy, organic position, HTTP-only, no browser. Delta mode returns only what moved.&lt;/p&gt;

&lt;p&gt;Scheduled config ready to clone: &lt;strong&gt;&lt;a href="https://apify.com/zhorex/tyre-price-monitor/examples/uk-tyre-price-changes-daily" rel="noopener noreferrer"&gt;UK tyre price changes — daily delta on popular sizes&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The five delta patterns behind it are in &lt;a href="https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e"&gt;&lt;em&gt;Scraping a price is easy. Knowing it changed is the product.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Hit a promoted-duplicate problem on another retailer? Reply with the shape of the response — the fix is usually this same three-line dedupe.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>One price was in cents, the other in euros. Same JSON, same product.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 14:32:50 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/one-price-was-in-cents-the-other-in-euros-same-json-same-product-hj0</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/one-price-was-in-cents-the-other-in-euros-same-json-same-product-hj0</guid>
      <description>&lt;p&gt;A product on Europe's largest online pharmacy came back looking like this:&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;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;995&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"listPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1450&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prices"&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;"default"&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;"amount"&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.95&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;Both numbers are correct. &lt;strong&gt;&lt;code&gt;price&lt;/code&gt; is in cents. &lt;code&gt;prices.default.amount&lt;/code&gt; is in euros.&lt;/strong&gt; Same response, same product, two units, no field name warning you.&lt;/p&gt;

&lt;p&gt;Pick the wrong one and you do not get an error — you get a €9.95 product priced at €995, a 100× outlier that sails through every sanity check that only looks for nulls. Then your buyer's "RRP breach" alert fires on the entire catalogue.&lt;/p&gt;

&lt;p&gt;Worse: the marketplace-offer array indexes in cents &lt;em&gt;again&lt;/em&gt;, so a naive "cheapest across offers" comparison silently mixes both scales.&lt;/p&gt;

&lt;p&gt;The fix is not clever, it is just explicit — and the comment matters more than the code:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_pick_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return euros.

    Unit trap: top-level `price` / `listPrice` are CENTS, nested
    `prices.*.amount` are EUROS, and the offers array is cents again.
    Never mix them — a 100x error passes every null check.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;nested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nested&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&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="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;          &lt;span class="c1"&gt;# already euros
&lt;/span&gt;    &lt;span class="n"&gt;cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cents&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;General rule worth internalising:&lt;/strong&gt; whenever an API exposes the same quantity twice, assume the units differ until you have proved otherwise on a product whose real price you can look up by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that does not exist
&lt;/h2&gt;

&lt;p&gt;My first version asked the search backend for a &lt;code&gt;url&lt;/code&gt; attribute and got back... nothing. Not an error — just no field, silently absent, on every single record.&lt;/p&gt;

&lt;p&gt;The attribute is called &lt;code&gt;deeplink&lt;/code&gt;. Same story with the recommended retail price: I asked for &lt;code&gt;strikePrice&lt;/code&gt; (a name I had assumed) when the real field is &lt;code&gt;listPrice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A hosted search API given an unknown attribute name usually &lt;strong&gt;omits it rather than complaining&lt;/strong&gt;. So if a field is mysteriously always empty, check the spelling against a raw unfiltered record before you debug your parser:&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;ATTRS&lt;/span&gt; &lt;span class="o"&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;pzn&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;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;brand&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;price&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;listPrice&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;pricePerUnit&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;stockStatus&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;deeplink&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;averageRating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PZN is the join key, and it is the whole product
&lt;/h2&gt;

&lt;p&gt;German pharmacy retail has something most e-commerce verticals lack: a &lt;strong&gt;national article number (PZN)&lt;/strong&gt; that identifies the exact pack — substance, strength, count, manufacturer. It is the join key that makes cross-retailer comparison honest.&lt;/p&gt;

&lt;p&gt;That is what turns a scrape into an asset. With PZN you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compare the same pack across shops without fuzzy title matching&lt;/li&gt;
&lt;li&gt;detect an &lt;strong&gt;RRP breach&lt;/strong&gt; — the shop selling below the manufacturer's list price, which is a competitive signal, not a typo&lt;/li&gt;
&lt;li&gt;watch the &lt;strong&gt;marketplace seller undercutting the shop itself&lt;/strong&gt; on its own product page&lt;/li&gt;
&lt;li&gt;track &lt;strong&gt;stock flips&lt;/strong&gt; per pack, which is the leading indicator for a supply problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One brand is one request covering up to 1,000 articles, so a competitive set is cheap to watch daily. &lt;code&gt;DOPPELHERZ&lt;/code&gt; is 335 articles in a single call; &lt;code&gt;ABTEI&lt;/code&gt; 148.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why daily, and why only the diff
&lt;/h2&gt;

&lt;p&gt;OTC pharmacy prices move constantly — promotions, marketplace sellers, stock. A full snapshot every day is mostly noise you already had. What a category manager acts on is:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;below_rrp&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;below_rrp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rrp_breach&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stock_flip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                  &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;   &lt;span class="c1"&gt;# never emitted, never billed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two disciplines that keep this honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A first run has no baseline&lt;/strong&gt;, so everything looks new. Charging a change price for a snapshot is a bug, not a pricing decision. Bill the baseline as a snapshot or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the watermark before charging.&lt;/strong&gt; Bill first and crash before saving, and the next run re-detects and re-bills the same changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Packaged as &lt;strong&gt;&lt;a href="https://apify.com/zhorex/pharmacy-price-monitor" rel="noopener noreferrer"&gt;DE/AT Pharmacy Price &amp;amp; Stock Monitor&lt;/a&gt;&lt;/strong&gt;: PZN-keyed OTC shelf data across DE and AT — price, list-price breach, discount, availability, and the marketplace seller undercutting the shop. HTTP-only, no browser, no login. Delta mode returns only price moves, RRP breaches and stock flips.&lt;/p&gt;

&lt;p&gt;Scheduled config ready to clone: &lt;strong&gt;&lt;a href="https://apify.com/zhorex/pharmacy-price-monitor/examples/de-pharmacy-brand-price-changes" rel="noopener noreferrer"&gt;DE pharmacy price &amp;amp; stock changes — daily delta&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The five patterns behind the delta engine are written up in &lt;a href="https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e"&gt;&lt;em&gt;Scraping a price is easy. Knowing it changed is the product.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you hit a units bug like this one on another retail API, reply with the field names — the cents/euros pair shows up far more often than it should.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>A hostel bed has four prices. Three of them are hidden.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 14:31:18 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/a-hostel-bed-has-four-prices-three-of-them-are-hidden-4b3b</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/a-hostel-bed-has-four-prices-three-of-them-are-hidden-4b3b</guid>
      <description>&lt;p&gt;If you scrape one accommodation price, you have a number. It is nearly useless.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;€48&lt;/code&gt; tells you nothing on its own. What a revenue manager, an OTA analyst or a travel-market researcher actually acts on is the &lt;em&gt;shape&lt;/em&gt; of that number over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it sat at €48 for three weeks, then moved twice in one day&lt;/li&gt;
&lt;li&gt;it dropped to €41 exactly nine days before arrival&lt;/li&gt;
&lt;li&gt;the property stopped being listed for that date entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;None of those facts exist in any single response.&lt;/strong&gt; No rate site publishes "here is what I charged yesterday". So if you do not store the previous state, the interesting part is not merely missing — it is unknowable.&lt;/p&gt;

&lt;p&gt;That reframing is the whole engineering problem, and it has a pleasant consequence: once your scraper keeps state, it can return &lt;em&gt;changes&lt;/em&gt; instead of rows, and a daily run over a whole city costs almost nothing on a quiet day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid, not the row
&lt;/h2&gt;

&lt;p&gt;The unit of state cannot be "a property". It has to be a &lt;strong&gt;cell&lt;/strong&gt;: &lt;code&gt;(property × arrival date × guest configuration)&lt;/code&gt;. A hostel bed is priced per arrival date and per party size, and those are independent axes.&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;property_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;arrival_date&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;guests&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nights&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;now&lt;/span&gt;  &lt;span class="o"&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;lowest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lowest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dorm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dorm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;promo_stack&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;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowest&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="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowest&lt;/span&gt;&lt;span class="sh"&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="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lowest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promo&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="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promo_changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# unchanged: do not emit, do not bill
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One city over 30 forward dates is 30 requests and a full month of the priced competitive set — because a whole city arrives in &lt;strong&gt;one&lt;/strong&gt; request per date. London returns 83 properties at once; Munich 17. That is what makes daily monitoring viable rather than a crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that cost me a whole baseline
&lt;/h2&gt;

&lt;p&gt;I shipped this same pattern on a reviews scraper first, and the second run reported &lt;strong&gt;every item as new&lt;/strong&gt;. The fingerprints were fine. The &lt;em&gt;ordering&lt;/em&gt; was not.&lt;/p&gt;

&lt;p&gt;The baseline run had paged through results in the site's default &lt;strong&gt;relevance&lt;/strong&gt; order. The next run happened to load them in &lt;strong&gt;date&lt;/strong&gt; order. Two runs, two disjoint slices of the same set — so nothing matched and the diff was garbage.&lt;/p&gt;

&lt;p&gt;The fix is one line, and it belongs in every delta scraper you write:&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="c1"&gt;# Delta mode must pin the sort order ALWAYS — not only when a baseline exists.
# Relevance ordering is not stable between runs.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sort_deterministically&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stop_after_all_seen_pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the order and "I hit a full page of already-seen items" becomes a trustworthy stop signal instead of a coin flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four prices, not one
&lt;/h2&gt;

&lt;p&gt;The headline rate is the least interesting field on the page. On this surface each cell carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the lowest available rate&lt;/strong&gt; — what a price-comparison table would show&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the dorm / private split&lt;/strong&gt; — two different products under one property, and they move independently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the pre-discount rate plus the promo stack&lt;/strong&gt;, with discount percentages. This is the one competitors miss: a property holding rack rate while stacking a 25% promo is running a very different strategy from one that simply cut its rate, and only the stack tells them apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the commission split for that date&lt;/strong&gt; — the economics of the listing, not just its price&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the &lt;strong&gt;organic ranking position&lt;/strong&gt;, which answers a question the rate alone cannot: did they get cheaper, or did they get &lt;em&gt;promoted&lt;/em&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  Absence is a signal
&lt;/h2&gt;

&lt;p&gt;The awkward case is a property that vanishes from a date it was priced on yesterday. Sold out, pulled, or de-listed — the response simply does not contain it. There is no row to diff.&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;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;previous_keys&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current_keys&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# only knowable by comparison
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rank the lifecycle change first.&lt;/strong&gt; When a cell both moved price and disappeared, a buyer's alerting cares far more about "it is gone" than "it is €3 cheaper".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first run has no baseline&lt;/strong&gt;, so everything looks new. Billing a change rate for what is really a snapshot is not a pricing choice, it is a bug. Bill the baseline at snapshot rate, or not at all — and persist the watermark &lt;em&gt;before&lt;/em&gt; charging, or a crash makes you re-bill the same changes next run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;I packaged this as &lt;strong&gt;&lt;a href="https://apify.com/zhorex/hostelworld-rate-monitor" rel="noopener noreferrer"&gt;Hostelworld Rate &amp;amp; Availability Monitor&lt;/a&gt;&lt;/strong&gt;: forward-dated rate calendar, dorm/private split, promo stack with discount percentages, organic ranking position and the per-date commission split — HTTP-only, no login, no API key, no browser. Delta mode returns only the cells that moved.&lt;/p&gt;

&lt;p&gt;There is a scheduled config ready to clone here: &lt;strong&gt;&lt;a href="https://apify.com/zhorex/hostelworld-rate-monitor/examples/london-hostel-rate-changes-daily" rel="noopener noreferrer"&gt;London hostel rate changes — daily delta&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dateStepDays: 7&lt;/code&gt; is the cheap way to watch a long season — same weekday each week instead of every date.&lt;/p&gt;

&lt;p&gt;If you would rather build your own, the five patterns behind the delta engine are written up in &lt;a href="https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e"&gt;&lt;em&gt;Scraping a price is easy. Knowing it changed is the product.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Watching a rate surface I have not covered? Reply with the source and I will tell you whether it is reachable anonymously and what the delta key should be.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The paginator returned HTTP 200 with zero results. 13,843 products were missing.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 14:29:40 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/the-paginator-returned-http-200-with-zero-results-13843-products-were-missing-4h61</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/the-paginator-returned-http-200-with-zero-results-13843-products-were-missing-4h61</guid>
      <description>&lt;p&gt;Every price-monitoring tutorial shows you the happy path: hit the search endpoint, ask for a big page size, walk the pages, done. Here is what actually happened when I pointed that at a UK safety-equipment catalogue with ~15,000 priced SKUs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hitsPerPage=1000&amp;amp;page=0&lt;/code&gt; → 1,000 products. Perfect.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hitsPerPage=1000&amp;amp;page=1&lt;/code&gt; → &lt;strong&gt;HTTP 200, zero hits.&lt;/strong&gt; No error. No warning. No &lt;code&gt;hasMore: false&lt;/code&gt;. An empty array dressed up as a successful response.&lt;/p&gt;

&lt;p&gt;If you trust that, you ship a monitor that reports "1,000 products" forever and nobody notices the other 13,843. Your buyer builds a dashboard on a tenth of the shelf.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the paginator lies
&lt;/h2&gt;

&lt;p&gt;Most hosted search backends cap how deep you can page — often at ~1,000 results — because deep offsets are expensive to compute. The polite ones return an error explaining it. This one returns success with nothing in it, which is worse, because your code has no signal to react to.&lt;/p&gt;

&lt;p&gt;So stop paginating by &lt;em&gt;position&lt;/em&gt; and paginate by &lt;em&gt;value&lt;/em&gt; instead. Sort by price ascending, then use the last price you saw as a filter for the next request:&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;body&lt;/span&gt; &lt;span class="o"&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;hitsPerPage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sortBy&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;price:asc&lt;/span&gt;&lt;span class="sh"&gt;"&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;last_price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numericFilters&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="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;net_price_value&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_price&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request starts where the previous one stopped, so you never ask for a deep offset at all. Result on this catalogue: &lt;strong&gt;14,843 SKUs in 16 requests&lt;/strong&gt;, no cap, and it self-heals if the catalogue grows mid-crawl.&lt;/p&gt;

&lt;p&gt;Two details worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ties.&lt;/strong&gt; If several products share a price, a strict &lt;code&gt;&amp;gt;&lt;/code&gt; skips the ones after the 1,000th at that exact value. In practice you either accept the (tiny) loss or add a secondary sort key and filter on the pair. Measure before you optimise — on a real price distribution the collision count is usually zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any sortable field works.&lt;/strong&gt; Price is convenient here; an &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;created_at&lt;/code&gt; cursor is often better if the field is monotonic and dense.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whenever a paginator returns &lt;strong&gt;200-with-nothing&lt;/strong&gt;, look for a sortable field you can use as a cursor. It is almost always faster than the offset path it replaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other trap: the same product, twice
&lt;/h2&gt;

&lt;p&gt;Promoted or sponsored placements appear in the result set &lt;em&gt;again&lt;/em&gt; at their natural position. Same SKU, two different &lt;code&gt;index&lt;/code&gt; values. If you emit both, you have inflated the buyer's row count — and if you bill per row, you have overcharged them for a duplicate they can spot in five seconds.&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;by_sku&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="nb"&gt;str&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="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;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by_sku&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="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# keep the natural position, not the promoted slot
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;by_sku&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And a 406 I caused myself
&lt;/h2&gt;

&lt;p&gt;The sitemap fetch kept returning &lt;code&gt;406 Not Acceptable&lt;/code&gt;. The endpoint was fine; my client was sending &lt;code&gt;Accept: text/html&lt;/code&gt; at an XML resource. One header:&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;headers&lt;/span&gt; &lt;span class="o"&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&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;application/xml,text/xml,*/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth remembering the general shape: a 406 is almost never the server being broken, it is your &lt;code&gt;Accept&lt;/code&gt; header disagreeing with what the resource can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it a product
&lt;/h2&gt;

&lt;p&gt;A full catalogue snapshot is a big, boring file. What a purchasing manager or a competitor-pricing analyst actually wants is the &lt;strong&gt;diff&lt;/strong&gt;: which SKUs moved, which appeared, which quietly vanished.&lt;/p&gt;

&lt;p&gt;That last one is the interesting one, because a discontinued product does not announce itself. It simply stops being in the response. You can only detect it by comparing against what you stored last time:&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;gone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# the discontinuation signal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means: &lt;strong&gt;rank the lifecycle change above the numeric one.&lt;/strong&gt; When a SKU both moved price and disappeared from a category, the buyer's alerting rules care far more about "it is gone" than "it is £1.05 cheaper". I emit &lt;code&gt;lifecycle_change&lt;/code&gt; first for exactly that reason.&lt;/p&gt;

&lt;p&gt;And save the watermark &lt;em&gt;before&lt;/em&gt; you charge. If you bill first and crash before persisting, the next run re-detects the same changes and bills them again — which is indefensible and entirely avoidable:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# persist BEFORE the charge
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shelf-change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A first run has no baseline by definition, so everything looks new. Charging a change price for what is really a snapshot is not a pricing decision, it is a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields that matter more than price
&lt;/h2&gt;

&lt;p&gt;On a trade catalogue the price alone is not enough to act on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ex-VAT and inc-VAT&lt;/strong&gt;, separately. A trade buyer reasons in ex-VAT; a consumer-facing competitor lists inc-VAT. Mixing them produces confidently wrong comparisons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum order quantity.&lt;/strong&gt; £6.95 per pair of gloves means something different at MOQ 1 than at MOQ 50.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit.&lt;/strong&gt; "Per pair" vs "per box of 100" is the single most common false-positive in industrial price comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle stage&lt;/strong&gt;, when the catalogue exposes it. It flags a discontinuation weeks before the SKU disappears.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;I packaged the above as &lt;strong&gt;&lt;a href="https://apify.com/zhorex/ppe-shelf-monitor" rel="noopener noreferrer"&gt;UK PPE &amp;amp; Safety Equipment Shelf Monitor&lt;/a&gt;&lt;/strong&gt; on Apify: the whole priced catalogue in one run, HTTP-only (no browser, so it costs a fraction of a browser-based scraper), and a delta mode that returns only the rows that moved — price up or down, new listing, delisting — each annotated with what changed.&lt;/p&gt;

&lt;p&gt;There is a prefilled example run on the Actor page, and a scheduled config here: &lt;strong&gt;&lt;a href="https://apify.com/zhorex/ppe-shelf-monitor/examples/uk-ppe-shelf-changes-daily" rel="noopener noreferrer"&gt;UK PPE shelf changes — daily delta&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you would rather build your own, the five patterns behind the delta engine — watermarks, fingerprints, price-cursor pagination, cell-state comparison, and save-before-billing — are written up in &lt;a href="https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e"&gt;&lt;em&gt;Scraping a price is easy. Knowing it changed is the product.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Monitoring a shelf I have not covered? Reply in the comments with the source and I will tell you whether it is reachable and what the delta key should be.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Scraping a price is easy. Knowing it changed is the product.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 26 Jul 2026 02:30:25 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/scraping-a-price-is-easy-knowing-it-changed-is-the-product-4h4e</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Most scraping tutorials stop at "here is the JSON". But almost nobody wants a snapshot — they want to know &lt;strong&gt;what moved since last time&lt;/strong&gt;. That difference is not a formatting detail, it is where all the engineering lives: the source usually publishes no history, so if you don't store the previous state, the change is simply unknowable.&lt;/p&gt;

&lt;p&gt;This post is the five patterns I use for that, the traps that cost me real money to find, and why "return only what changed" also happens to be the honest way to bill.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the snapshot is the wrong unit
&lt;/h2&gt;

&lt;p&gt;Take a hotel rate. You scrape it today: €48. Useful? Barely. Now scrape it every morning and the interesting facts appear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it dropped to €41 nine days before arrival&lt;/li&gt;
&lt;li&gt;it was €48 for three weeks, then moved twice in one day&lt;/li&gt;
&lt;li&gt;the property stopped being listed for that date entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those facts exist in any single response. &lt;strong&gt;They exist only in the diff&lt;/strong&gt;, and the source will never hand you the diff — no rate site publishes "here is what I charged yesterday". The same is true of restaurant availability, pharmacy stock, and whether a SKU quietly vanished from a catalogue.&lt;/p&gt;

&lt;p&gt;So the state you keep &lt;em&gt;is&lt;/em&gt; the dataset. That reframing has a pleasant consequence: if your scraper stores state, it can bill for changes instead of rows, and a daily run over 5,000 SKUs costs the buyer almost nothing on a quiet day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 1 — Monotonic id watermark (cheapest, when you can get it)
&lt;/h2&gt;

&lt;p&gt;If the source hands out ids that only ever increase, you don't need to store content at all. Store the highest id you saw and stop paginating the moment you cross it.&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;last_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watermark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;highest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_id&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;channel_history&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;message&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="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;last_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;                    &lt;span class="c1"&gt;# everything below here was seen on a previous run
&lt;/span&gt;    &lt;span class="n"&gt;highest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;highest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&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="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watermark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;highest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost: one integer. Works for Telegram-style message feeds, forum post ids, filings sequences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap&lt;/strong&gt;: this is only sound if ids are monotonic &lt;em&gt;and&lt;/em&gt; the feed is ordered. Plenty of APIs return "recommended" order by default, which brings us to the expensive lesson.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 2 — Content fingerprint (when ids are unstable)
&lt;/h2&gt;

&lt;p&gt;Reviews are the classic case: no usable id, edited text, and the same review can surface under different internal keys. So hash the parts that identify it.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;basis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&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="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="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;180&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="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then keep a set of seen fingerprints and emit only misses.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trap that cost me a whole baseline
&lt;/h3&gt;

&lt;p&gt;I shipped exactly this on a hotel-reviews scraper and the second run reported &lt;strong&gt;every review as new&lt;/strong&gt;. The fingerprints were fine. The ordering wasn't.&lt;/p&gt;

&lt;p&gt;The baseline run had paged through reviews in the site's default &lt;em&gt;relevance&lt;/em&gt; order. The next run happened to load them in &lt;em&gt;date&lt;/em&gt; order. Two runs, two disjoint slices of the same 4,000 reviews — so nothing matched, and the diff was garbage.&lt;/p&gt;

&lt;p&gt;The fix is one line, and it belongs in every delta scraper you write:&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="c1"&gt;# Delta mode must pin the sort order, ALWAYS — not just when a baseline exists.
# Relevance ordering is not stable between runs, so baseline and delta end up
# describing different slices and every fingerprint misses.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sort_newest_first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;delta_stop_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;        &lt;span class="c1"&gt;# one all-seen page is now enough to stop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the order, and "I hit a full page of already-seen items" becomes a trustworthy stop signal instead of a coin flip.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 3 — Price watermark pagination (when the paginator lies)
&lt;/h2&gt;

&lt;p&gt;A UK catalogue I monitor exposes ~15,000 SKUs through a search API with a &lt;code&gt;page&lt;/code&gt; parameter. At &lt;code&gt;hitsPerPage=1000&lt;/code&gt;, requesting &lt;code&gt;page=1&lt;/code&gt; returns &lt;strong&gt;HTTP 200 with zero hits&lt;/strong&gt;. No error, no warning — a silent cap dressed up as an empty shelf.&lt;/p&gt;

&lt;p&gt;If you trust it, you ship a scraper that reports "1,000 products" forever and nobody notices the other 14,000.&lt;/p&gt;

&lt;p&gt;The workaround is to stop paginating by position and paginate by &lt;em&gt;value&lt;/em&gt; instead — sort by price and use the last price you saw as a filter:&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;body&lt;/span&gt; &lt;span class="o"&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;hitsPerPage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sortBy&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;price:asc&lt;/span&gt;&lt;span class="sh"&gt;"&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;last_price&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numericFilters&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="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;net_price_value&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_price&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;14,843 SKUs in 16 requests, no cap, and it self-heals if the catalogue grows mid-crawl. Any time a paginator returns 200-with-nothing, look for a sortable field you can use as a cursor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 4 — Cell-state comparison (for things that are &lt;em&gt;absent&lt;/em&gt;)
&lt;/h2&gt;

&lt;p&gt;Availability is the awkward one: &lt;strong&gt;a sold-out restaurant returns nothing&lt;/strong&gt;. There is no row to diff. The signal is the disappearance.&lt;/p&gt;

&lt;p&gt;So the unit of state can't be a row, it has to be a cell in a grid you define — (venue × date × party size), (SKU × market), (property × arrival date):&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;venue_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;party_size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;now&lt;/span&gt;  &lt;span class="o"&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;slots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;prime_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slots&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;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sold_out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# only knowable by comparison
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reopened&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&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="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slots&lt;/span&gt;&lt;span class="sh"&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="n"&gt;NOISE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                &lt;span class="c1"&gt;# unchanged: don't emit, don't bill
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A noise floor.&lt;/strong&gt; Slot counts jitter by ±1 constantly. Without a threshold your "changes" feed is mostly noise, and if you bill per change you are billing for jitter — which is the fastest way to earn a one-star review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rank first.&lt;/strong&gt; When several things change at once, emit the lifecycle change (listed / delisted / sold out) ahead of the numeric one. A buyer's alerting rules care about "it disappeared" far more than "it moved by 3".&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Pattern 5 — Save the state &lt;em&gt;before&lt;/em&gt; you bill
&lt;/h2&gt;

&lt;p&gt;Mundane, and the one that actually hurts if you get it wrong. Write the new watermark before charging, and make the first run of a stream cheap:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# persist BEFORE the charge
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_first_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you charge first and crash before persisting, the next run re-detects the same changes and bills them again. And a first run has no baseline by definition — everything looks new, so charging a "change" price for what is really a snapshot is indefensible. Bill the baseline at snapshot rate, or not at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this looks like shipped
&lt;/h2&gt;

&lt;p&gt;I built five monitors on these patterns this month, each one aimed at a source that publishes no history of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/zhorex/hostelworld-rate-monitor" rel="noopener noreferrer"&gt;Hostelworld Rate &amp;amp; Availability Monitor&lt;/a&gt;&lt;/strong&gt; — forward-dated accommodation rates, promo stack with discount percentages, organic ranking position, per-date commission split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/zhorex/resy-availability-monitor" rel="noopener noreferrer"&gt;Resy Availability &amp;amp; Scarcity Monitor&lt;/a&gt;&lt;/strong&gt; — restaurant slots by date and party size, prime-window (18:30-20:59) scarcity, sold-out and reopened transitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/zhorex/pharmacy-price-monitor" rel="noopener noreferrer"&gt;DE/AT Pharmacy Price &amp;amp; Stock Monitor&lt;/a&gt;&lt;/strong&gt; — OTC prices, RRP breaches, stock status across DE/AT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/zhorex/tyre-price-monitor" rel="noopener noreferrer"&gt;UK Tyre Price &amp;amp; Fitment Monitor&lt;/a&gt;&lt;/strong&gt; — tyre prices by size and postcode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/zhorex/ppe-shelf-monitor" rel="noopener noreferrer"&gt;UK PPE Shelf Monitor&lt;/a&gt;&lt;/strong&gt; — 14K+ SKUs, price moves, new listings and delistings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All five run on a schedule, keep their own watermark, and return only the cells that moved. If you'd rather build your own, the patterns above are the whole trick — the state store is the moat, not the parser.&lt;/p&gt;




&lt;h2&gt;
  
  
  One last thing: run your own demo input
&lt;/h2&gt;

&lt;p&gt;While writing this I ran all 18 of my public Actors with &lt;strong&gt;exactly the prefilled input a first-time user gets&lt;/strong&gt;, and three came back with zero rows.&lt;/p&gt;

&lt;p&gt;The worst one was my highest-traffic Actor. Its example profile URL still returned &lt;strong&gt;HTTP 200&lt;/strong&gt;. The mode worked fine anonymously — other profiles returned data immediately. That one account had simply gone quiet, and every evaluator who pressed Run saw an empty dataset.&lt;/p&gt;

&lt;p&gt;A status check on the URL would have said "fine". A schema validator would have said "fine". The only thing that catches it is running the thing and counting rows.&lt;/p&gt;

&lt;p&gt;So if you maintain a scraper with a demo input: go run it right now, unedited, and count the rows. It takes a minute and mine had rotted without a single error anywhere.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you build monitors for a living and want the delta logic explained for a specific source, reply in the comments — happy to dig into the pattern that fits it.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to get Pinnacle odds (and no-vig fair prices) with Python in 2026 — after Pinnacle killed its public API</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Tue, 21 Jul 2026 00:36:56 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/how-to-get-pinnacle-odds-and-no-vig-fair-prices-with-python-in-2026-after-pinnacle-killed-its-27g8</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/how-to-get-pinnacle-odds-and-no-vig-fair-prices-with-python-in-2026-after-pinnacle-killed-its-27g8</guid>
      <description>&lt;p&gt;If you build betting models, run a +EV or arbitrage tool, or just track closing-line value, you already know the one line everyone benchmarks against: &lt;strong&gt;Pinnacle&lt;/strong&gt;. Lowest margins, highest limits, no winner bans — its price is the closest thing the industry has to a "true price."&lt;/p&gt;

&lt;p&gt;The catch: in &lt;strong&gt;July 2025 Pinnacle shut down its public API&lt;/strong&gt;, restricting access to "select high-value bettors and commercial partnerships." A lot of tooling broke overnight. Here's a practical, keyless way to still pull Pinnacle's lines in Python — and, more importantly, turn them into the number you actually bet on: the &lt;strong&gt;no-vig fair price&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: a live Pinnacle line, de-vigged
&lt;/h2&gt;

&lt;p&gt;Pulled a few minutes ago — Pinnacle's moneyline on a Champions League qualifier:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thun vs Dinamo Zagreb&lt;/strong&gt; (h2h) — overround &lt;strong&gt;7.1%&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;Outcome&lt;/th&gt;
&lt;th&gt;Pinnacle price&lt;/th&gt;
&lt;th&gt;Implied %&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;No-vig fair %&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Fair odds&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thun (home)&lt;/td&gt;
&lt;td&gt;3.45&lt;/td&gt;
&lt;td&gt;29.0%&lt;/td&gt;
&lt;td&gt;27.1%&lt;/td&gt;
&lt;td&gt;3.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draw&lt;/td&gt;
&lt;td&gt;3.68&lt;/td&gt;
&lt;td&gt;27.2%&lt;/td&gt;
&lt;td&gt;25.4%&lt;/td&gt;
&lt;td&gt;3.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dinamo (away)&lt;/td&gt;
&lt;td&gt;1.96&lt;/td&gt;
&lt;td&gt;51.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The raw prices bake in Pinnacle's ~7% margin. Strip it out and Dinamo's &lt;em&gt;fair&lt;/em&gt; price is &lt;strong&gt;2.10&lt;/strong&gt;, not 1.96 — that de-vigged number is what you compare every other book against to find +EV. The math is tiny:&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;prices&lt;/span&gt; &lt;span class="o"&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;home&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;away&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.96&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;implied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;span class="n"&gt;overround&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                 &lt;span class="c1"&gt;# 1.071  -&amp;gt; 7.1% vig
&lt;/span&gt;&lt;span class="n"&gt;fair_prob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;overround&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;implied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;span class="n"&gt;fair_odds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fair_prob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;  &lt;span class="c1"&gt;# away -&amp;gt; 2.10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do that across every book and the gaps are your edge. The hard part was never the math — it's getting Pinnacle's price reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the odds
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/zhorex/sports-odds-aggregator" rel="noopener noreferrer"&gt;Pinnacle Odds Scraper&lt;/a&gt; does the fetch, keyless (no login, no API key). Minimal input:&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pre_match_and_live"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sports"&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;"soccer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tennis"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"markets"&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;"h2h"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spreads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"totals"&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;You get one normalized row per outcome — &lt;code&gt;homeTeam&lt;/code&gt;, &lt;code&gt;awayTeam&lt;/code&gt;, &lt;code&gt;league&lt;/code&gt;, &lt;code&gt;marketType&lt;/code&gt;, &lt;code&gt;outcomeLabel&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;commenceTime&lt;/code&gt;, &lt;code&gt;isLive&lt;/code&gt; — for pre-match &lt;strong&gt;and&lt;/strong&gt; in-play. Add &lt;code&gt;"specials"&lt;/code&gt; for Pinnacle's 5,000+ per-sport markets (futures, exact-totals, first-to-score, team props) — the depth most odds feeds don't carry. Pay-per-use: &lt;strong&gt;$0.01&lt;/strong&gt; per pre-match snapshot, &lt;strong&gt;$0.02&lt;/strong&gt; live, &lt;strong&gt;$0.04&lt;/strong&gt; for the specials tier — no $249/mo subscription, you pay for what you poll.&lt;/p&gt;

&lt;p&gt;Honest note: &lt;a href="https://the-odds-api.com" rel="noopener noreferrer"&gt;The Odds API&lt;/a&gt; also carries Pinnacle's main markets from $30/mo if you want a fixed-quota REST API — this actor's edge is the specials depth, the built-in no-vig/CLV derivation, and per-use pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's a recurring job, not a one-off
&lt;/h2&gt;

&lt;p&gt;Two numbers matter to a serious bettor, and both need repeated pulls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The line when you bet&lt;/strong&gt; — odds move every few seconds; you snapshot at decision time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The closing line&lt;/strong&gt; — the single best predictor of long-run edge. Capturing &lt;strong&gt;CLV&lt;/strong&gt; means pulling the price again right before kickoff, every event you track.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the natural setup is a schedule: point it at your leagues, run it every minute pre-match and near-continuously toward kickoff, and log the series. Turn on delta mode + an Apify Schedule and each run only returns what changed — a gap-free odds history you pay for only when the line actually moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sports don't have an off-season
&lt;/h2&gt;

&lt;p&gt;Soccer, tennis, basketball, MMA, baseball — something sharp is priced year-round, so a Pinnacle feed is about as evergreen as alt-data gets. If you're building anything that needs a true-price benchmark, this is the input.&lt;/p&gt;

&lt;p&gt;Ready-to-clone presets (closing-line EV, live in-play, specials/futures) are on the &lt;a href="https://apify.com/zhorex/sports-odds-aggregator" rel="noopener noreferrer"&gt;Actor's examples tab&lt;/a&gt;. &lt;em&gt;Questions or a market you need? Open an issue on the Actor page.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to pull China A-share sentiment signals — retail, analyst &amp; news — with Python (2026)</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:29:04 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/how-to-pull-china-a-share-sentiment-signals-retail-analyst-news-with-python-2026-2kll</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/how-to-pull-china-a-share-sentiment-signals-retail-analyst-news-with-python-2026-2kll</guid>
      <description>&lt;p&gt;For a quant or alt-data desk, Chinese A-shares are a rich, under-covered sentiment universe. FactSet literally sells "Guba Analytics" and "Xueqiu Analytics" side by side as retail-sentiment feeds, and every sell-side desk in China publishes ratings daily. The problem is plumbing: three different sources, three different anti-bot setups, no clean API.&lt;/p&gt;

&lt;p&gt;Here's how to pull the three layers that actually move a name — &lt;strong&gt;retail chatter, analyst ratings, and live news&lt;/strong&gt; — keyless, in Python, and fuse them into one read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: Kweichow Moutai (600519) right now
&lt;/h2&gt;

&lt;p&gt;I ran all three on 贵州茅台 (Kweichow Moutai) — China's bellwether liquor stock — a few minutes ago. Here's the picture that falls out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Retail sentiment (Eastmoney Guba 股吧)&lt;/strong&gt; — net sentiment &lt;strong&gt;+0.13&lt;/strong&gt; across the latest posts (2 positive / 13 neutral / 0 negative). The retail crowd is buzzing about a price hike:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Post&lt;/th&gt;
&lt;th&gt;Sentiment&lt;/th&gt;
&lt;th&gt;Reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;飞天茅台价格大涨 — "Feitian Moutai price surges"&lt;/td&gt;
&lt;td&gt;positive&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;茅台宣布涨价 — "Moutai announces price increase"&lt;/td&gt;
&lt;td&gt;neutral&lt;/td&gt;
&lt;td&gt;681&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i茅台半小时售罄 — "i-Moutai app sold out in 30 min"&lt;/td&gt;
&lt;td&gt;neutral&lt;/td&gt;
&lt;td&gt;873&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;2. Analyst ratings (Eastmoney 研报)&lt;/strong&gt; — the sell-side is unanimous. The latest reports, keyed to the ticker:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Broker&lt;/th&gt;
&lt;th&gt;Rating&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;诚通证券 (Chengtong)&lt;/td&gt;
&lt;td&gt;买入 Buy&lt;/td&gt;
&lt;td&gt;maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;华鑫证券 (Huaxin)&lt;/td&gt;
&lt;td&gt;买入 Buy&lt;/td&gt;
&lt;td&gt;maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;万联证券 (Wanlian)&lt;/td&gt;
&lt;td&gt;增持 Accumulate&lt;/td&gt;
&lt;td&gt;maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;开源证券 (Kaiyuan)&lt;/td&gt;
&lt;td&gt;买入 Buy&lt;/td&gt;
&lt;td&gt;maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;3. News flow (CLS 财联社)&lt;/strong&gt; — the live telegraph stream, each flash tagged with a bull/bear score and the tickers it names, so market-moving headlines wire straight into an event model.&lt;/p&gt;

&lt;p&gt;Put together: retail mildly bullish on a price-hike story, analysts uniformly Buy/Accumulate, steady news flow — a coherent, quantifiable read you can track over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pull each one
&lt;/h2&gt;

&lt;p&gt;Each is a separate keyless Actor (no API key, no login). Minimal inputs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retail sentiment&lt;/strong&gt; — &lt;a href="https://apify.com/zhorex/guba-sentiment-monitor" rel="noopener noreferrer"&gt;China A-Share Guba Sentiment Monitor&lt;/a&gt;:&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;"stockCodes"&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;"600519"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"300750"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sentimentAnalysis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Returns each post with a bull/bear score, read/reply counts, and a per-ticker rollup (net sentiment, share-of-voice, positive/negative split).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analyst ratings&lt;/strong&gt; — &lt;a href="https://apify.com/zhorex/china-analyst-reports" rel="noopener noreferrer"&gt;China A-Share Analyst Research Monitor&lt;/a&gt;:&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;"reportType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stockCodes"&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;"600519"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ratingChangesOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Set &lt;code&gt;ratingChangesOnly&lt;/code&gt; to get just the upgrades and downgrades — the tradable event is a broker moving a rating (增持 → 买入 is an upgrade).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;News flow&lt;/strong&gt; — &lt;a href="https://apify.com/zhorex/cls-flash-news" rel="noopener noreferrer"&gt;China Financial Flash News Monitor (CLS 财联社)&lt;/a&gt;:&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;"stockCodes"&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;"600519"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sentimentAnalysis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Filters the real-time telegraph to flashes that name your watchlist.&lt;/p&gt;

&lt;p&gt;For the price/retail layer of the same name, &lt;a href="https://apify.com/zhorex/xueqiu-scraper" rel="noopener noreferrer"&gt;Xueqiu (雪球)&lt;/a&gt; is the other retail source institutions buy alongside Guba.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fusing it into one signal
&lt;/h2&gt;

&lt;p&gt;The three layers answer different questions — what retail feels, what the sell-side rates, what just happened. Blended per ticker (retail net-sentiment + a rating score + news flow), they give you a single composite bull/bear read you can rank a watchlist by. Because each Actor keys everything on a stable id, you can turn on delta mode + an Apify Schedule and each run returns only what's new — a gap-free per-ticker sentiment time series, paying only for genuinely new items.&lt;/p&gt;

&lt;p&gt;That time series — not any single snapshot — is what a systematic model actually consumes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions or a field you need? Open an issue on any of the Actor pages.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to track Chinese social media trends and brand mentions with Python (Weibo, RedNote, Bilibili) in 2026</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Fri, 17 Jul 2026 21:07:55 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/how-to-track-chinese-social-media-trends-and-brand-mentions-with-python-weibo-rednote-bilibili-590p</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/how-to-track-chinese-social-media-trends-and-brand-mentions-with-python-weibo-rednote-bilibili-590p</guid>
      <description>&lt;p&gt;China's social platforms — Weibo, RedNote (Xiaohongshu), Bilibili, Douban — are where a billion people talk about brands, products and news every day. For anyone doing market research, brand monitoring, or alt-data, they're also a black box: most Western scraping tools don't cover them, and the ones that do usually break on the anti-bot walls.&lt;/p&gt;

&lt;p&gt;Here's a practical, keyless way to pull two things developers actually ask for — &lt;strong&gt;what's trending right now&lt;/strong&gt;, and &lt;strong&gt;who's mentioning a brand&lt;/strong&gt; — without maintaining a browser farm.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's trending on Weibo right now
&lt;/h2&gt;

&lt;p&gt;Weibo's hot-search board (微博热搜) is the single best real-time pulse of the Chinese internet. Here's the live top 10, pulled today (2026‑07‑17) in one run, with a rough English gloss:&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;Trending topic&lt;/th&gt;
&lt;th&gt;English gloss&lt;/th&gt;
&lt;th&gt;Heat&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;年轻人一定要对钱有概念&lt;/td&gt;
&lt;td&gt;"Young people must get a sense of money"&lt;/td&gt;
&lt;td&gt;218,466&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;功夫女足让韩国人破防了&lt;/td&gt;
&lt;td&gt;China women's football stuns Korea&lt;/td&gt;
&lt;td&gt;198,056&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;智能体互信互联互操作全球合作倡议&lt;/td&gt;
&lt;td&gt;Global initiative for AI‑agent interoperability&lt;/td&gt;
&lt;td&gt;141,701&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;雅迪把717骑行节搬进欢乐谷&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yadea&lt;/strong&gt; brings its 7·17 cycling festival to a theme park&lt;/td&gt;
&lt;td&gt;138,658&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;看不见的咖啡因终于可视化了&lt;/td&gt;
&lt;td&gt;"Invisible caffeine, finally visualized"&lt;/td&gt;
&lt;td&gt;98,229&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;西班牙晒大力神杯&lt;/td&gt;
&lt;td&gt;Spain shows off the World Cup trophy&lt;/td&gt;
&lt;td&gt;95,479&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;歌手排名&lt;/td&gt;
&lt;td&gt;Singer rankings (music show)&lt;/td&gt;
&lt;td&gt;67,683&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;青岛与世界干杯&lt;/td&gt;
&lt;td&gt;Qingdao toasts the world (beer festival)&lt;/td&gt;
&lt;td&gt;51,537&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;阿根廷决赛遇苦主裁判&lt;/td&gt;
&lt;td&gt;Argentina's "nemesis referee" in the final&lt;/td&gt;
&lt;td&gt;46,982&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;百花杀热度&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Baihua Sha&lt;/em&gt; drama buzz&lt;/td&gt;
&lt;td&gt;46,722&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice &lt;strong&gt;#4&lt;/strong&gt;: that's a brand — Yadea, the e‑bike maker — riding a trending hashtag off a live event. That's exactly the signal a brand or agency wants to catch the moment it happens, not a week later in a report.&lt;/p&gt;

&lt;p&gt;Pulling this board is keyless (no login, no cookie):&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hot_search"&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's the entire input. You get &lt;code&gt;rank&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;hotValue&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt; and a freshness flag per topic. Actor: &lt;a href="https://apify.com/zhorex/weibo-scraper" rel="noopener noreferrer"&gt;Weibo Scraper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To turn a one‑off pull into a hands‑off feed, switch on &lt;code&gt;hot_search_delta&lt;/code&gt; (or &lt;code&gt;deltaMode&lt;/code&gt; for keyword search) plus a daily Apify Schedule — each run returns only what &lt;strong&gt;changed&lt;/strong&gt; (new / rising / dropped topics), so you build a gap‑free trend history and pay only for genuinely new items.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching a brand across every platform at once
&lt;/h2&gt;

&lt;p&gt;Trends are the top of the funnel. The recurring question is narrower: &lt;em&gt;what are people saying about my brand this week, everywhere?&lt;/em&gt; Doing that means running five different scrapers with five different anti‑bot quirks and stitching the output together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/zhorex/chinese-brand-monitor" rel="noopener noreferrer"&gt;Chinese Brand Monitor&lt;/a&gt; collapses that into one scheduled call. Give it a brand name and it watches &lt;strong&gt;Weibo + RedNote + Bilibili + Douban + Xueqiu&lt;/strong&gt; together, returns a unified mention feed with a bull/bear sentiment score per mention, and — the part people miss — it &lt;strong&gt;auto‑localizes the brand name&lt;/strong&gt;: pass &lt;code&gt;Nike&lt;/code&gt; and it also searches &lt;code&gt;耐克&lt;/code&gt;, &lt;code&gt;Tesla&lt;/code&gt; → &lt;code&gt;特斯拉&lt;/code&gt;, so you don't lose the ~half of mentions that use the Chinese name.&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;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nike"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platforms"&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;"weibo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rednote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bilibili"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"douban"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sentimentAnalysis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Prefer the raw platforms directly? They're standalone too:&lt;br&gt;
&lt;a href="https://apify.com/zhorex/rednote-xiaohongshu-scraper" rel="noopener noreferrer"&gt;RedNote / Xiaohongshu&lt;/a&gt; · &lt;a href="https://apify.com/zhorex/bilibili-scraper" rel="noopener noreferrer"&gt;Bilibili&lt;/a&gt; · &lt;a href="https://apify.com/zhorex/weibo-scraper" rel="noopener noreferrer"&gt;Weibo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keyless matters
&lt;/h2&gt;

&lt;p&gt;Every actor above is built to run on plain datacenter proxies for the public surfaces — no phone‑verified account to babysit, no login farm. Keyword search on RedNote and Weibo can be deepened with a &lt;code&gt;cookieString&lt;/code&gt; you supply, but the trend boards, comments and public profiles work out of the box. You pay per result (or per genuinely‑new result in delta mode), so a quiet day costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into a pipeline
&lt;/h2&gt;

&lt;p&gt;The stickiest setup: an Apify Schedule (daily/hourly) + a webhook or a Make/Zapier/Slack integration, so each finished run pushes straight into your warehouse or a channel. Pair it with delta mode and the monitor runs and delivers itself — no one opens a console.&lt;/p&gt;

&lt;p&gt;If you're tracking a brand, a category, or the Chinese internet's mood in general, that's the whole loop: trends board for discovery, brand monitor for the narrow question, delta + schedule to keep it fresh.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions or a field you need? Open an issue on any of the actor pages — happy to help.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Foreign vs domestic in China: 525 social posts on who's winning the sentiment war</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Tue, 07 Jul 2026 22:36:58 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/foreign-vs-domestic-in-china-525-social-posts-on-whos-winning-the-sentiment-war-1ghl</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/foreign-vs-domestic-in-china-525-social-posts-on-whos-winning-the-sentiment-war-1ghl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I scored 525 Chinese social posts on two foreign-vs-domestic rivalries. Coffee: Starbucks still beats Luckin on sentiment (+52% vs +38%) even though Luckin won the market. Sportswear: it flips hard — domestic Anta crushes Nike on sentiment (+43% vs &lt;strong&gt;+11%&lt;/strong&gt;, with Nike at 37% negative). Market share and engagement are not brand love, and whether a foreign brand keeps China's affection is category-dependent. You only see it if you read the Chinese platforms Western tools don't cover.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Is this Western brand winning or losing in China?" is usually answered with sales figures. But sales tell you what people &lt;em&gt;bought&lt;/em&gt;, not what they &lt;em&gt;feel&lt;/em&gt; — and in China, national-pride sentiment (国潮, &lt;em&gt;guochao&lt;/em&gt;) is moving fast. So I pulled 5 days of aggregated Chinese social chatter (no personal data) on two rivalries and scored the sentiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Platforms:&lt;/strong&gt; Weibo (微博) + Bilibili (哔哩哔哩), some Douban&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Window:&lt;/strong&gt; 5 days · &lt;strong&gt;Sample:&lt;/strong&gt; 525 brand mentions, sentiment-scored&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline:&lt;/strong&gt; cross-platform collection → dedup → Chinese-language sentiment → brand-level aggregation. Aggregate signals only — no individual posts, usernames, or personal data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Case 1 — Coffee: Starbucks still wins the hearts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brand&lt;/th&gt;
&lt;th&gt;Mentions (SoV)&lt;/th&gt;
&lt;th&gt;Positive&lt;/th&gt;
&lt;th&gt;Negative&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Net sentiment&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;Engagement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Starbucks 星巴克&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;126 (52%)&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+52%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;97,165&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Luckin 瑞幸&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;116 (48%)&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;28%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+38%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;141,776&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Luckin overtook Starbucks on stores and revenue — yet share of voice is a near-tie and &lt;strong&gt;Starbucks still runs 14 points higher net sentiment&lt;/strong&gt;. Luckin wins the &lt;em&gt;market&lt;/em&gt; and the &lt;em&gt;engagement&lt;/em&gt; (its viral collabs drive +46% more interaction), but its aggressive-discount playbook also earns more mixed feelings. Market share ≠ brand love.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2 — Sportswear: the flip. Domestic Anta crushes Nike
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brand&lt;/th&gt;
&lt;th&gt;Mentions (SoV)&lt;/th&gt;
&lt;th&gt;Positive&lt;/th&gt;
&lt;th&gt;Negative&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Net sentiment&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;Engagement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nike 耐克&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;161 (57%)&lt;/td&gt;
&lt;td&gt;48%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+11%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;854,626&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Anta 安踏&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;122 (43%)&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+43%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;342,742&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here it inverts completely. Nike dominates the &lt;em&gt;conversation&lt;/em&gt; (more mentions, 2.5× the engagement) — but that conversation is &lt;strong&gt;divisive and cool&lt;/strong&gt;: only +11% net, with &lt;strong&gt;37% outright negative&lt;/strong&gt;. Domestic challenger Anta runs &lt;strong&gt;+43% net&lt;/strong&gt;. In sportswear, the &lt;em&gt;guochao&lt;/em&gt; effect is real and the foreign brand is losing the sentiment war even while it "wins" the volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that matters
&lt;/h2&gt;

&lt;p&gt;Two foreign brands, two opposite outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bigger buzz / engagement ≠ better sentiment.&lt;/strong&gt; Luckin and Nike both out-engage their rivals while scoring &lt;em&gt;worse&lt;/em&gt; on feeling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The foreign-brand premium is category-dependent.&lt;/strong&gt; Coffee still grants Starbucks affection; sportswear has turned on Nike. A single "China sentiment" number per brand would hide all of this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you sell into China, this is the layer under the sales figure — and it lives on &lt;strong&gt;Weibo, Xiaohongshu/RedNote, Bilibili and Douban&lt;/strong&gt;, which most Western social-listening tools barely cover. That coverage gap is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it on your own brands
&lt;/h2&gt;

&lt;p&gt;I run this with the &lt;strong&gt;Chinese Brand Monitor&lt;/strong&gt; actor on Apify (Weibo + RedNote + Bilibili + Douban + Xueqiu, one scheduled call, sentiment + share-of-voice built in). Point it at your brand and its competitors and you get these same tables on a daily or weekly cadence.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Chinese Brand Monitor on Apify:&lt;/strong&gt; &lt;a href="https://apify.com/zhorex/chinese-brand-monitor" rel="noopener noreferrer"&gt;https://apify.com/zhorex/chinese-brand-monitor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prefer it as a managed monthly feed (delivered dataset, no setup, no calls)? Reply here or email &lt;strong&gt;&lt;a href="mailto:samimassis2002@gmail.com"&gt;samimassis2002@gmail.com&lt;/a&gt;&lt;/strong&gt; and I'll send a free one-brand sample for your names.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All figures are aggregated and anonymized — brand-level signals only, no personal records.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
    </item>
    <item>
      <title>We asked China's most-used AI about 5 global brands. Two of them are missing from half the answers.</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sun, 28 Jun 2026 17:14:03 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/we-asked-chinas-most-used-ai-about-5-global-brands-two-of-them-are-missing-from-half-the-answers-42a6</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/we-asked-chinas-most-used-ai-about-5-global-brands-two-of-them-are-missing-from-half-the-answers-42a6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — We asked &lt;strong&gt;DeepSeek&lt;/strong&gt; (one of the AI engines hundreds of millions of Chinese consumers now ask instead of searching) the same buyer questions you'd ask any assistant — "what are the best running shoes?", "which EV should I buy?" — for five global brands. The result: visibility on China's AI is &lt;strong&gt;wildly uneven&lt;/strong&gt;. Nike, Apple and Tesla show up in 100% of relevant answers; &lt;strong&gt;Starbucks appeared in only 50%&lt;/strong&gt;, and &lt;strong&gt;McDonald's was the top pick just 1 out of 3 times&lt;/strong&gt; (KFC keeps beating it). In every category, Chinese challengers quietly surface. If you only track ChatGPT/Gemini, you can't see any of this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why we ran this
&lt;/h2&gt;

&lt;p&gt;"GEO" — generative engine optimization, i.e. making sure AI assistants mention your brand — is the new SEO. But almost every GEO tool tracks only the Western engines (ChatGPT, Gemini, Claude). Meanwhile, in China, &lt;strong&gt;900M+ people now ask AI assistants&lt;/strong&gt; like DeepSeek, Qwen, Kimi and GLM for product recommendations. That's a blind spot for any brand selling into — or competing in — the China market.&lt;/p&gt;

&lt;p&gt;So we ran a quick pilot with our own tool, the &lt;a href="https://apify.com/zhorex/ai-brand-visibility-monitor" rel="noopener noreferrer"&gt;AI Brand Visibility Monitor&lt;/a&gt;, across five categories, asking DeepSeek the kind of questions real buyers ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we found
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Western brand&lt;/th&gt;
&lt;th&gt;Appeared in&lt;/th&gt;
&lt;th&gt;Ranked #1&lt;/th&gt;
&lt;th&gt;Sentiment&lt;/th&gt;
&lt;th&gt;Chinese rivals surfacing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sportswear&lt;/td&gt;
&lt;td&gt;Nike&lt;/td&gt;
&lt;td&gt;4/4 (100%)&lt;/td&gt;
&lt;td&gt;4/4&lt;/td&gt;
&lt;td&gt;+1.0&lt;/td&gt;
&lt;td&gt;Li-Ning, Anta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smartphones&lt;/td&gt;
&lt;td&gt;Apple&lt;/td&gt;
&lt;td&gt;4/4 (100%)&lt;/td&gt;
&lt;td&gt;3/4&lt;/td&gt;
&lt;td&gt;+0.75&lt;/td&gt;
&lt;td&gt;Huawei, Xiaomi, Oppo, Vivo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Electric vehicles&lt;/td&gt;
&lt;td&gt;Tesla&lt;/td&gt;
&lt;td&gt;4/4 (100%)&lt;/td&gt;
&lt;td&gt;3/4&lt;/td&gt;
&lt;td&gt;+1.0&lt;/td&gt;
&lt;td&gt;BYD, NIO, XPeng, Li Auto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast food&lt;/td&gt;
&lt;td&gt;McDonald's&lt;/td&gt;
&lt;td&gt;3/4 (75%)&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;+1.0&lt;/td&gt;
&lt;td&gt;KFC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coffee&lt;/td&gt;
&lt;td&gt;Starbucks&lt;/td&gt;
&lt;td&gt;2/4 (50%)&lt;/td&gt;
&lt;td&gt;1/2&lt;/td&gt;
&lt;td&gt;+1.0&lt;/td&gt;
&lt;td&gt;Luckin Coffee, Manner&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Sentiment is a −1…+1 score on the mention; "Appeared in" = share of the four category prompts where the brand was named at all; "Ranked #1" = how often it was the first brand named.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Being mentioned is not the same as being recommended.&lt;/strong&gt; McDonald's appeared in most answers but was the &lt;em&gt;top&lt;/em&gt; pick only once in three — KFC, the runaway fast-food leader in China, kept taking the #1 spot. Tesla and Apple each lost #1 once too, to Chinese rivals (BYD/NIO; Huawei/Xiaomi).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real risk isn't bad sentiment — it's invisibility.&lt;/strong&gt; Sentiment was positive everywhere (+0.75 to +1.0). The problem for Starbucks isn't that DeepSeek dislikes it; it's that DeepSeek often &lt;strong&gt;doesn't bring it up at all&lt;/strong&gt; (50%), while Luckin Coffee and Manner — its Chinese challengers — do surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chinese challengers show up in every single category.&lt;/strong&gt; A brand monitoring only Western engines would never see that, in China's AI, Li-Ning, BYD, Huawei, KFC and Luckin are part of the consideration set.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How we measured it (methodology)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engine:&lt;/strong&gt; DeepSeek (this pilot; the tool also covers Qwen, Kimi, GLM and the Western engines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brands &amp;amp; prompts:&lt;/strong&gt; 5 categories, one focal Western brand each, 4 real buyer-intent prompts per category (category, comparison, recommendation, and direct-brand questions), with the main Chinese competitors supplied for share-of-voice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What each "check" returns:&lt;/strong&gt; for one brand × one prompt × one engine — whether the brand was &lt;strong&gt;mentioned&lt;/strong&gt;, the &lt;strong&gt;sentiment&lt;/strong&gt; of that mention, its &lt;strong&gt;rank&lt;/strong&gt; versus the named competitors, &lt;strong&gt;share-of-voice&lt;/strong&gt;, and the &lt;strong&gt;sources&lt;/strong&gt; the engine cited.&lt;/li&gt;
&lt;li&gt;We report outcomes only — what the engines answer, not how the data is gathered.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations (so nobody over-reads it)
&lt;/h2&gt;

&lt;p&gt;This is a &lt;strong&gt;snapshot pilot&lt;/strong&gt;: 5 brands × 4 prompts × 1 engine = 20 checks, single run. AI answers are volatile — studies suggest a large share of brand citations shift month to month — so a single run is a photo, not a movie. The point isn't statistical proof; it's a live demonstration that &lt;strong&gt;AI visibility in China diverges from what Western tools show, and that it's measurable.&lt;/strong&gt; A fuller study (more brands, more engines, weekly tracking) is the obvious next step — which is exactly what the tool is built to do on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  See your own brand
&lt;/h2&gt;

&lt;p&gt;Want to know whether DeepSeek, Qwen, Kimi and GLM mention &lt;em&gt;your&lt;/em&gt; brand — and who they recommend instead? The &lt;a href="https://apify.com/zhorex/ai-brand-visibility-monitor" rel="noopener noreferrer"&gt;AI Brand Visibility Monitor&lt;/a&gt; runs this for any brand and prompt set, across Chinese &lt;strong&gt;and&lt;/strong&gt; Western engines, on a schedule. Run it once with no API key for a free sample.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;It's the only tool that covers the Chinese AI engines — the engines Profound gates to Enterprise and most mid-market GEO tools don't track at all.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Your brand has an AI-search blind spot — and it's in China (DeepSeek, Qwen, Kimi, GLM)</title>
      <dc:creator>Sami</dc:creator>
      <pubDate>Sat, 27 Jun 2026 23:53:10 +0000</pubDate>
      <link>https://dev.to/sami_8858131362756585e4f4/your-brand-has-an-ai-search-blind-spot-and-its-in-china-deepseek-qwen-kimi-glm-4heh</link>
      <guid>https://dev.to/sami_8858131362756585e4f4/your-brand-has-an-ai-search-blind-spot-and-its-in-china-deepseek-qwen-kimi-glm-4heh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TL;DR — When someone asks an AI &lt;em&gt;"what's the best running-shoe brand?"&lt;/em&gt;, that answer is the new shelf placement. There are good tools to track that on ChatGPT/Perplexity/Gemini. There are almost none for the &lt;strong&gt;Chinese&lt;/strong&gt; engines — DeepSeek, Qwen, Kimi, GLM — where 900M+ people now ask instead of searching. I built one that does both in a single run. &lt;a href="https://apify.com/zhorex/ai-brand-visibility-monitor" rel="noopener noreferrer"&gt;Try it on Apify.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shelf moved into the answer
&lt;/h2&gt;

&lt;p&gt;Search is being replaced by &lt;em&gt;answers&lt;/em&gt;. People don't scroll ten blue links anymore — they ask an assistant and take the brands it names. So the question every brand now has to answer is no longer "where do I rank on Google?" but &lt;strong&gt;"do the AI engines even mention me — and how?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That discipline has a name now: &lt;strong&gt;GEO (Generative Engine Optimization)&lt;/strong&gt;. The tooling is real and growing fast. But there's a hole in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot: the Chinese engines
&lt;/h2&gt;

&lt;p&gt;Every GEO/AI-visibility tool I could find covers the Western engines — ChatGPT, Gemini, and friends. Almost &lt;strong&gt;none&lt;/strong&gt; cover the Chinese ones: DeepSeek, Qwen/Tongyi, Kimi/Moonshot, GLM/Zhipu, where hundreds of millions of people now ask product questions, and where AI already drives a big chunk of product discovery.&lt;/p&gt;

&lt;p&gt;If you sell into China — or your competitors do — that's the part of your AI visibility you literally cannot see today. That gap is the whole reason I built this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it measures
&lt;/h2&gt;

&lt;p&gt;Give it your &lt;strong&gt;brand&lt;/strong&gt;, the &lt;strong&gt;questions your customers actually ask&lt;/strong&gt;, and the &lt;strong&gt;competitors&lt;/strong&gt; you care about. For every &lt;code&gt;prompt × engine&lt;/code&gt; it returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mentioned?&lt;/strong&gt; — is your brand named in the answer at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment&lt;/strong&gt; — positive / neutral / negative + a −1…1 score (English &lt;strong&gt;and&lt;/strong&gt; Chinese lexicon)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rank&lt;/strong&gt; — where you sit vs the competitors you listed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share of voice&lt;/strong&gt; — you vs rivals in that specific answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cited sources&lt;/strong&gt; — the URLs the engine surfaced (for search-enabled engines)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer snippet&lt;/strong&gt; — the evidence behind the score&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One record per &lt;code&gt;brand × prompt × engine&lt;/code&gt;. Export JSON/CSV/Excel or pull it into your BI.&lt;/p&gt;

&lt;h2&gt;
  
  
  A monitor, not a one-off
&lt;/h2&gt;

&lt;p&gt;A single snapshot of "does DeepSeek mention me" is nearly worthless, because &lt;strong&gt;AI answers are volatile&lt;/strong&gt; — they move week to week. The value is the &lt;strong&gt;trend&lt;/strong&gt;: did you just drop out of Qwen's answer? did a competitor overtake you on DeepSeek this week?&lt;/p&gt;

&lt;p&gt;Turn on &lt;strong&gt;delta mode&lt;/strong&gt; and put it on a schedule, and each run returns only what &lt;strong&gt;changed&lt;/strong&gt; since last time — so a quiet week costs almost nothing and a real shift surfaces the moment it happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example output
&lt;/h2&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;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nike"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"What are the best running shoe brands?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mentioned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sentiment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"positive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sentimentScore"&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.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;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"competitorsMentioned"&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;"Adidas"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Li-Ning"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shareOfVoice"&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.333&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"answerSnippet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"For running shoes, the most recommended brands are Adidas, Nike and Li-Ning..."&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;h2&gt;
  
  
  How to run it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Set &lt;strong&gt;brand&lt;/strong&gt; and a handful of &lt;strong&gt;prompts&lt;/strong&gt; (the questions your customers ask).&lt;/li&gt;
&lt;li&gt;Pick &lt;strong&gt;engines&lt;/strong&gt; and add your &lt;strong&gt;own API key&lt;/strong&gt; for each in &lt;code&gt;apiKeys&lt;/code&gt; — pure pay-as-you-go, no subscription. (Run it with &lt;strong&gt;no key&lt;/strong&gt; and you get a free labeled &lt;strong&gt;sample&lt;/strong&gt; so you can see the output shape first.)&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;competitorBrands&lt;/strong&gt;, turn on &lt;strong&gt;deltaMode&lt;/strong&gt;, attach a &lt;strong&gt;Schedule&lt;/strong&gt; → a hands-off weekly visibility feed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pricing is pay-per-event: &lt;strong&gt;$0.25 per visibility check&lt;/strong&gt; (1 brand × 1 prompt × 1 engine), small add-ons for delta + competitors. You pay your own LLM usage on your own keys. Far below the $270–$2,000/mo enterprise GEO platforms — and the only one that sees the Chinese engines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://apify.com/zhorex/ai-brand-visibility-monitor" rel="noopener noreferrer"&gt;AI Brand Visibility Monitor on Apify&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you track brand visibility across AI search, which engines/prompts would you most want covered next (Doubao? ERNIE?)? Tell me in the comments and I'll likely ship it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>marketing</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
