<?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: Matvey</title>
    <description>The latest articles on DEV Community by Matvey (@matveyg).</description>
    <link>https://dev.to/matveyg</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%2F4108814%2F458cdc93-81e5-41b6-966c-33d6453a5c2b.jpg</url>
      <title>DEV Community: Matvey</title>
      <link>https://dev.to/matveyg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/matveyg"/>
    <language>en</language>
    <item>
      <title>46 Runs, One Route, the Same Cheapest Flight Every Time. The Two Sources Never Once Agreed on Its Price</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Sat, 07 Nov 2026 13:14:14 +0000</pubDate>
      <link>https://dev.to/matveyg/46-runs-one-route-the-same-cheapest-flight-every-time-the-two-sources-never-once-agreed-on-its-5ap4</link>
      <guid>https://dev.to/matveyg/46-runs-one-route-the-same-cheapest-flight-every-time-the-two-sources-never-once-agreed-on-its-5ap4</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them does something that sounds trivial and is not: it runs the same flight search on Google Flights and KAYAK at once, puts both answers in one dataset, and adds a row saying which source was cheaper and by how much.&lt;/p&gt;

&lt;p&gt;That verdict row is the product. It is also the part I trusted least, so I let my own health check hammer it: the same input every day since 6 October — &lt;code&gt;DPS&lt;/code&gt; → &lt;code&gt;SIN&lt;/code&gt;, one way, 15 March 2027, economy, 30 rows per source. &lt;strong&gt;46 successful runs between 6 October and 6 November.&lt;/strong&gt; This is what a month of that looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both sources agree on the flight. Neither agrees on the price
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;46 of 46 runs&lt;/strong&gt; the cheapest row from Google Flights and the cheapest row from KAYAK are the same physical flight: Jetstar &lt;strong&gt;JQ86&lt;/strong&gt;, departing 14:55, landing 18:00, A321neo. No matching heuristics needed — same flight number, same minute.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;46 of 46 runs the two prices differ.&lt;/strong&gt; Not once in a month did they land on the same number for that seat.&lt;/p&gt;

&lt;p&gt;The asymmetry in &lt;em&gt;how&lt;/em&gt; they differ is the interesting part. Google's price for JQ86 across the month took &lt;strong&gt;three distinct values&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;Google Flights, JQ86&lt;/th&gt;
&lt;th&gt;runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;$60&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$59&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$65&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One step, on 27 October, between a run at 00:10 and a run at 20:07. Otherwise flat for three weeks at a time.&lt;/p&gt;

&lt;p&gt;KAYAK's price for the same flight, same runs, took &lt;strong&gt;eleven distinct values between $57 and $76&lt;/strong&gt; — a 33% spread — and moved inside the hour:&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;20:07&lt;/th&gt;
&lt;th&gt;20:09&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;27 Oct&lt;/td&gt;
&lt;td&gt;$67&lt;/td&gt;
&lt;td&gt;$63&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;20:10&lt;/th&gt;
&lt;th&gt;20:27&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;31 Oct&lt;/td&gt;
&lt;td&gt;$61&lt;/td&gt;
&lt;td&gt;$68&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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;20:05&lt;/th&gt;
&lt;th&gt;20:11&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 Nov&lt;/td&gt;
&lt;td&gt;$68&lt;/td&gt;
&lt;td&gt;$61&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the verdict flipped: &lt;strong&gt;Google cheaper in 25 runs, KAYAK in 21.&lt;/strong&gt; A coin toss, and the coin is KAYAK's, because Google's side of it barely moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seller column explains all of it
&lt;/h2&gt;

&lt;p&gt;KAYAK is a metasearch engine, so its price is not the airline's price — it is whichever agency KAYAK had surfaced at the moment the run stopped reading. The row says who:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;seller on the JQ86 row&lt;/th&gt;
&lt;th&gt;prices seen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jetstar (direct)&lt;/td&gt;
&lt;td&gt;$57, $61, $63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Booking.com&lt;/td&gt;
&lt;td&gt;$62, $63, $67, $68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotogate&lt;/td&gt;
&lt;td&gt;$68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kiwi.com&lt;/td&gt;
&lt;td&gt;$69, $74, $76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CheapOair&lt;/td&gt;
&lt;td&gt;$73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Justfly.com&lt;/td&gt;
&lt;td&gt;$75, $76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priceline&lt;/td&gt;
&lt;td&gt;$79, $83, $84, $85&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google's rows carry no seller at all — &lt;code&gt;seller&lt;/code&gt; was null on all 29 Google rows of the latest run, because Google Flights hands off to the airline or an agency after the click rather than quoting one on the results page.&lt;/p&gt;

&lt;p&gt;Part of KAYAK's spread is my own fault, and I wrote that up separately: KAYAK's search is a poll that fills in with sellers while you read it, and a row cap that is satisfied early stops the read early, so &lt;code&gt;price&lt;/code&gt; is the minimum of a partially arrived set. With the cap fixed at 30 for all 46 runs, the remaining movement is sellers rotating in and out of the cheapest slot.&lt;/p&gt;

&lt;p&gt;Either way the practical rule is the same, and it is not "KAYAK is dearer":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap between two metasearch engines is mostly a seller-rotation gap, not a fare gap.&lt;/strong&gt; A single run tells you which source was cheaper &lt;em&gt;in that minute&lt;/em&gt;. If you are publishing "source X is 8% cheaper on this route", you need a schedule, not a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  And across the rest of the board, the direction reverses
&lt;/h2&gt;

&lt;p&gt;Match every flight that both sources returned in the same run, on flight numbers plus departure minute, taking each source's cheapest row per flight. Excluding JQ86, that is &lt;strong&gt;530 matched pairs&lt;/strong&gt; over the month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;KAYAK cheaper on &lt;strong&gt;443&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Google cheaper on &lt;strong&gt;26&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;identical on &lt;strong&gt;61&lt;/strong&gt; (11.5%)&lt;/li&gt;
&lt;li&gt;median gap &lt;strong&gt;−3.1%&lt;/strong&gt; in KAYAK's favour&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So KAYAK is systematically a few percent cheaper on the body of the result set, and still loses the headline on the single cheapest flight more than half the time. If your pipeline reads only the floor — the cheapest row, the verdict's &lt;code&gt;cheaperSource&lt;/code&gt; — you are sampling the one row in the dataset with the most variance in it. Cheap insurance: compute your own median over matched flights alongside the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three joins that look right and are not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Join on flight numbers and the departure minute, nothing else.&lt;/strong&gt; Not on aircraft: KL836 in the latest run is &lt;code&gt;Boeing 777&lt;/code&gt; to Google and &lt;code&gt;Boeing 777-300ER&lt;/code&gt; to KAYAK, same seat, same price. Not on &lt;code&gt;departAt&lt;/code&gt; as a string either — Google sends &lt;code&gt;2027-03-15T14:55&lt;/code&gt;, KAYAK sends &lt;code&gt;2027-03-15T14:55:00&lt;/code&gt;. Slice to 16 characters first. (The Actor's own merge mode uses exactly that key.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expect the same flight twice from KAYAK.&lt;/strong&gt; Over the 46 runs KAYAK sent &lt;strong&gt;43 duplicate rows&lt;/strong&gt; — same flight, different seller, different price — and Google sent none. Today's control run has JQ86 twice: $61 from Jetstar and $67 from Booking.com. Deduplicate keeping the &lt;strong&gt;minimum&lt;/strong&gt;, not the first row. And do not use &lt;code&gt;sellersForThisFlight&lt;/code&gt; as a seller count while you do it: on those duplicate rows it reads &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codeshares do not merge, by design.&lt;/strong&gt; &lt;code&gt;QF5659&lt;/code&gt; appears in KAYAK's rows in &lt;strong&gt;all 46 runs&lt;/strong&gt; and in Google's in none — because Google folds it into JQ86's &lt;code&gt;legs[].operatedBy: "Qantas QF5659"&lt;/code&gt;. Same A321neo, same 14:55 departure. As QF5659 it was priced &lt;strong&gt;$83 to $116&lt;/strong&gt;; as JQ86, $61 to $68. A "cheapest flight per aircraft" report has to resolve that itself; no source will do it for you.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing before you write a dashboard: the two sources are not looking at the same shelf. Per run the overlap between Google's ~28 flights and KAYAK's ~29 was on average &lt;strong&gt;12.5 flights&lt;/strong&gt;, ranging 8 to 16. Each source's top-30 is its own window on the day, ranked by its own idea of "best".&lt;/p&gt;

&lt;h2&gt;
  
  
  The merge mode, and where it helps
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;outputMode&lt;/code&gt; to &lt;code&gt;byFlight&lt;/code&gt; and one row carries every source's price for that flight. LHR → CDG on 4 December, Google Flights plus Momondo, 8 rows each → &lt;strong&gt;12 flight rows, 4 of them merged&lt;/strong&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"airline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"British Airways"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"flightNumbers"&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;"BA310"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"departAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-12-04T13:20"&lt;/span&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;111&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cheapestSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-flights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceBySource"&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;"google-flights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;111&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"momondo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;132&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;"sellerBySource"&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;"google-flights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"momondo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eDreams"&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;"priceGap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;21&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;Momondo priced all four shared BA flights at exactly €132 against Google's €111 — a flat €21, which is a seller margin, not four independent fares. The other 8 of 12 rows came from one source only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~flight-price-comparison/run-sync-get-dataset-items?token=APIFY_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"origin":"DPS","destination":"SIN","departureDate":"2027-03-15",
       "sources":["google-flights","kayak"],"maxResultsPerSource":5,"currency":"USD"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today's control run of exactly that, from a clean account: &lt;strong&gt;28.6 seconds&lt;/strong&gt;, 9 flight rows plus a free verdict row. The verdict read &lt;code&gt;{"google-flights": 65, "kayak": 61}&lt;/code&gt; — KAYAK cheaper, the other way round from yesterday's run, which is the whole point of this article.&lt;/p&gt;

&lt;p&gt;Cost: the parent run charged &lt;strong&gt;$0&lt;/strong&gt;, and the two source Actors it started on the account charged &lt;strong&gt;$0.005&lt;/strong&gt; and &lt;strong&gt;$0.011&lt;/strong&gt;. That is how &lt;a href="https://apify.com/lergassy/flight-price-comparison?fpr=wmeplu" rel="noopener noreferrer"&gt;Flight Price Comparison&lt;/a&gt; is wired — each source is its own Actor run on your own account, billing its own rows, with no comparison fee on top. The side effect is a concurrency ceiling: four sources plus the parent is five concurrent runs, the entire Apify Free-plan allowance, so the Actor retries a source that gets refused (8 attempts, 15 seconds apart) instead of dropping it. Run times over the 46 health checks: median 40 s, range 12–126 s.&lt;/p&gt;

&lt;p&gt;A failing source writes a free &lt;code&gt;error&lt;/code&gt; row and the others still return, so a run is never all-or-nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;It needs an origin, a destination and a date — there is no browse mode, so "cheapest week in March from Bali" is a different job. Fares are point-of-sale dependent, and these reads are anonymous and currency-forced, so your own booking screen may differ. Round-trip rows are the total for both directions, and &lt;code&gt;departAt&lt;/code&gt;/&lt;code&gt;arriveAt&lt;/code&gt; span the whole trip — subtract them and you get a holiday, not a flight; use &lt;code&gt;legs&lt;/code&gt; or the &lt;code&gt;outbound*&lt;/code&gt;/&lt;code&gt;inbound*&lt;/code&gt; columns.&lt;/p&gt;

&lt;p&gt;And treat &lt;code&gt;gapPercent&lt;/code&gt; with care when the sources are shaped differently. On a STN → DUB test the verdict reported Ryanair cheaper by 177% — true in that both numbers are the cheapest row of their source, misleading in that Ryanair's row is the cheapest &lt;strong&gt;one-way fare of the day excluding bags&lt;/strong&gt; while KAYAK's was a priced round trip for two adults. The verdict compares the cheapest rows it was given. It does not promise they are the same product; on a single-source pair like Google plus KAYAK they usually are, and I checked that 46 times before saying so.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>travel</category>
    </item>
    <item>
      <title>Trump Posted 206 Times on 3 November. My Daily Read Saw 28 of Them</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Sat, 07 Nov 2026 05:14:52 +0000</pubDate>
      <link>https://dev.to/matveyg/trump-posted-206-times-on-3-november-my-daily-read-saw-28-of-them-4nak</link>
      <guid>https://dev.to/matveyg/trump-posted-206-times-on-3-november-my-daily-read-saw-28-of-them-4nak</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Truth Social: you hand it usernames or post links and it returns one row per post — text, exact publish time, likes, ReTruths, replies count, media files, link preview card, quoted post, author. No account, no cookies, no login. Truth Social runs a Mastodon-compatible API, and three of its endpoints answer a logged-out visitor: account lookup, an account's statuses, and a single status.&lt;/p&gt;

&lt;p&gt;I have been running a small health check on it twice a day since 5 October, which accidentally left me a logged corpus: &lt;strong&gt;169 runs, 4,475 rows, 1,000 distinct posts, some of them read 53 times&lt;/strong&gt;. Today I added one deliberate pull — 250 posts of &lt;a class="mentioned-user" href="https://dev.to/realdonaldtrump"&gt;@realdonaldtrump&lt;/a&gt;, 18 seconds, $1.00 on the Free plan — and the two together say something I had wrong in my own documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The posting rate is not a rate
&lt;/h2&gt;

&lt;p&gt;My health check asks for the 20 newest posts, twice a day. For most of October that was comfortable: 12 to 27 new posts a day from &lt;a class="mentioned-user" href="https://dev.to/realdonaldtrump"&gt;@realdonaldtrump&lt;/a&gt;, so a daily read caught almost everything.&lt;/p&gt;

&lt;p&gt;Then the 250-post pull came back with this day distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-11-07    3
2026-11-06    7
2026-11-05    4
2026-11-04    9
2026-11-03  206   &amp;lt;-
2026-11-02   21   (partial - the 250 cap cut here)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;206 posts on 3 November&lt;/strong&gt; — the day before the US midterms. 141 of them carry a link card to &lt;code&gt;swampthevoteusa.com&lt;/code&gt;, state-by-state endorsement lists: &lt;em&gt;"TO THE GREAT PEOPLE OF KENTUCKY, GET OUT AND VOTE FOR: – Andy Barr for U.S. Senate…"&lt;/em&gt;. The median gap between two of those 141 posts is &lt;strong&gt;9 seconds&lt;/strong&gt;, and 119 of the 140 gaps are under 30 seconds. One 15-minute window, 13:15–13:30 UTC, holds &lt;strong&gt;52 posts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I simulated what a schedule would have delivered that day, replaying the real timestamps against a read that returns the 20 newest posts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;Reads in the day&lt;/th&gt;
&lt;th&gt;Posts delivered of 206&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;every 15 min&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;138 (67%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hourly&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;79 (38%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;every 3 h&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;58 (28%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;daily&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20 (10%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 15-minute monitor — the cadence I recommend in my own README — would have &lt;strong&gt;missed a third of election eve&lt;/strong&gt;. Not because of a block, a timeout or a bug: because 20 is smaller than 52.&lt;/p&gt;

&lt;p&gt;The fix is not a faster schedule, it is a bigger bite. &lt;code&gt;maxPostsPerUser&lt;/code&gt; costs nothing when nothing new was posted, because my Actor charges per row and rows it has already delivered under an "only new posts" memory are free. Setting it to 200 on a 15-minute schedule costs exactly the same as 20 on 364 quiet days a year, and on the 365th it is the difference between a feed and a sample. I have raised my own default guidance accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;postedAfter&lt;/code&gt; does not define your window — the cap does
&lt;/h2&gt;

&lt;p&gt;I asked for &lt;code&gt;postedAfter: "7 days"&lt;/code&gt; with &lt;code&gt;maxPostsPerUser: 250&lt;/code&gt;. What came back spans &lt;strong&gt;4.2 days&lt;/strong&gt;, from 2 November 23:08 to 7 November 03:12. The timeline is read newest-first; the cap hit before the date did, and the oldest row sits mid-burst on 2 November.&lt;/p&gt;

&lt;p&gt;The row count alone cannot tell you this happened — 250 is just 250. What tells you is comparing the oldest &lt;code&gt;createdAt&lt;/code&gt; to the date you asked for. If they disagree, your window is truncated and you have no idea what is behind it. Any date-bounded pull needs that assertion:&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;oldest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;createdAt&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;oldest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;wanted_from&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;capped at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;oldest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, asked for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wanted_from&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;h2&gt;
  
  
  Catch a post in the first minute and its engagement is a rounding error
&lt;/h2&gt;

&lt;p&gt;The upside of a 15-minute schedule is latency. The cost is that every engagement number on a fresh post is close to meaningless, and my logged corpus happens to measure exactly how close.&lt;/p&gt;

&lt;p&gt;Twelve posts were first read within six hours of publication and read again just past the 24-hour mark. Likes at first read, as a share of the 24-hour value:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Age at first read&lt;/th&gt;
&lt;th&gt;Likes&lt;/th&gt;
&lt;th&gt;% of the 24 h value&lt;/th&gt;
&lt;th&gt;Account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6 seconds&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3 min&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;1.9%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.5 min&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;td&gt;2.0%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 min&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;21.6%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;35 min&lt;/td&gt;
&lt;td&gt;556&lt;/td&gt;
&lt;td&gt;21.8%&lt;/td&gt;
&lt;td&gt;WhiteHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.7 h&lt;/td&gt;
&lt;td&gt;8,256&lt;/td&gt;
&lt;td&gt;49.0%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.2 h&lt;/td&gt;
&lt;td&gt;3,494&lt;/td&gt;
&lt;td&gt;48.5%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.0 h&lt;/td&gt;
&lt;td&gt;6,034&lt;/td&gt;
&lt;td&gt;58.1%&lt;/td&gt;
&lt;td&gt;realDonaldTrump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4.7 h&lt;/td&gt;
&lt;td&gt;1,424&lt;/td&gt;
&lt;td&gt;70.7%&lt;/td&gt;
&lt;td&gt;WhiteHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5.2 h&lt;/td&gt;
&lt;td&gt;1,675&lt;/td&gt;
&lt;td&gt;71.5%&lt;/td&gt;
&lt;td&gt;WhiteHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The curve is monotone across two accounts and three orders of magnitude of audience. &lt;strong&gt;Half the likes arrive in the first two hours; a post caught at one minute old shows you 2% of them.&lt;/strong&gt; The 6-second row is the one worth remembering: &lt;code&gt;likesCount: 2&lt;/code&gt; on a post that finished the day at 13,707.&lt;/p&gt;

&lt;p&gt;Practically: a monitoring pipeline must treat the first row as &lt;em&gt;text and timestamp only&lt;/em&gt;. If you want engagement, re-read the post once a day later. The same post asked for twice is returned and billed once per run, so a second pass costs one row, and the exact &lt;code&gt;createdAt&lt;/code&gt; makes the age arithmetic trivial.&lt;/p&gt;

&lt;p&gt;One more wrinkle: counts are not monotone. Across 3,086 consecutive re-reads of the same post, &lt;strong&gt;57 (1.8%) came back with fewer likes than the previous read&lt;/strong&gt; — people unlike things. The largest drop I logged was 5. So &lt;code&gt;max()&lt;/code&gt; across reads, not &lt;code&gt;last()&lt;/code&gt;, if you want a high-water mark.&lt;/p&gt;

&lt;p&gt;Follower counts, unlike Telegram's rounded &lt;code&gt;6.3M&lt;/code&gt;, arrive exact to the digit, which makes a daily series readable. &lt;a class="mentioned-user" href="https://dev.to/realdonaldtrump"&gt;@realdonaldtrump&lt;/a&gt; over my 20 logged days: 13,094,290 → 13,173,870, &lt;strong&gt;+79,580&lt;/strong&gt;. The interesting rows are the outliers — &lt;strong&gt;−121 on 1 November&lt;/strong&gt; (a net loss, so the field really is a live count and not a counter) and &lt;strong&gt;+21,193 on 4 November&lt;/strong&gt;, the day after the 206-post burst, against a ~1,500/day baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  A third of the sample had no text — and then none of it did
&lt;/h2&gt;

&lt;p&gt;My Actor's &lt;code&gt;text&lt;/code&gt; field is plain text with the HTML stripped. In the October corpus, &lt;strong&gt;190 of 539 &lt;a class="mentioned-user" href="https://dev.to/realdonaldtrump"&gt;@realdonaldtrump&lt;/a&gt; posts (35.3%) had an empty &lt;code&gt;text&lt;/code&gt;&lt;/strong&gt;. All 190 had media: 103 single videos, 79 single images, 8 image sets. A keyword filter or an LLM classifier reading &lt;code&gt;text&lt;/code&gt; sees nothing on a third of his posts.&lt;/p&gt;

&lt;p&gt;Quote posts make it worse. Of 65 quotes in that sample, &lt;strong&gt;45 had an empty &lt;code&gt;quotedText&lt;/code&gt;&lt;/strong&gt; — the quoted post was itself a bare video or picture — and 40 of those also had no text of their own. The row is structurally complete and semantically empty; the only thing to read is a &lt;code&gt;.mp4&lt;/code&gt; URL.&lt;/p&gt;

&lt;p&gt;Then the election-week pull came back with just &lt;strong&gt;12 of 250 (4.8%)&lt;/strong&gt; empty, median text length 1,116 characters, and 164 of 250 carrying a link card. Same account, same field, same parser. So the useful number is not "35%" or "5%" — it is &lt;strong&gt;don't branch on text being present&lt;/strong&gt;. Check &lt;code&gt;mediaCount&lt;/code&gt; and &lt;code&gt;quotedMediaUrls&lt;/code&gt; before you decide a post said nothing, and expect the ratio to move with whatever the account is doing that week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the site will not give you without a login
&lt;/h2&gt;

&lt;p&gt;Measured on 1 October across the public API surface, and still true: &lt;code&gt;/api/v2/search&lt;/code&gt;, &lt;code&gt;/api/v1/trends&lt;/code&gt;, the trending-truths feed, &lt;code&gt;reblogged_by&lt;/code&gt;, &lt;code&gt;favourited_by&lt;/code&gt; and the reply tree all answer &lt;strong&gt;401&lt;/strong&gt; to a guest. &lt;code&gt;/api/v1/statuses/&amp;lt;id&amp;gt;/context&lt;/code&gt; is hard-blocked by the WAF every time. The hashtag timeline is the sneaky one — it answers &lt;strong&gt;HTTP 200 with &lt;code&gt;[]&lt;/code&gt;&lt;/strong&gt;, so a naive client reports "no posts about that tag" instead of "you are not allowed to ask".&lt;/p&gt;

&lt;p&gt;So: no search, no hashtag feeds, no trends, no comment threads. You bring the usernames. Each post row carries &lt;code&gt;repliesCount&lt;/code&gt;, which tells you a discussion exists without letting you read it. A handful of accounts also hide themselves from logged-out visitors — @DanScavino's lookup returns 401 — and those come back as a free error row rather than taking the run down.&lt;/p&gt;

&lt;p&gt;Transport is the other half. Datacenter addresses get Cloudflare's block page on &lt;strong&gt;100%&lt;/strong&gt; of requests. On residential addresses the verdict is per-address and sticky: roughly a quarter are challenged and stay challenged. The surprise was the TLS fingerprint — measuring 4 fingerprints × 8 sessions × 7 endpoints, the Firefox and Chrome 151 fingerprints passed while plain Chrome and Firefox 144 were challenged &lt;em&gt;from a good address&lt;/em&gt;. So the client keeps whatever session last worked and, on a challenge, rotates both the address and the fingerprint with a back-off. One more server-side quirk: &lt;code&gt;exclude_reblogs&lt;/code&gt; is accepted and ignored, so ReTruth filtering has to happen on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&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;"usernames"&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;"realDonaldTrump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WhiteHouse"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPostsPerUser"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postedAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"onlyNewRowsStore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trump-watch"&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;A real row from today's run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"117552785929148974"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://truthsocial.com/@realDonaldTrump/117552785929148974"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-03T13:39:24.203Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"realDonaldTrump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorFollowers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13175051&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TO THE GREAT PEOPLE OF KENTUCKY, GET OUT AND VOTE FOR:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;– Andy Barr for U.S. Senate&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&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;"isRepost"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isQuote"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repliesCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;111&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repostsCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"likesCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5092&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mediaTypes"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linkUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://swampthevoteusa.com/kentucky/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linkDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"swampthevoteusa.com"&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;From Python, run and read in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~truth-social-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&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;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;usernames&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;realDonaldTrump&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;postedAfter&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;1 day&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;maxPostsPerUser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;textless&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&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;p&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="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="nf"&gt;print&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;posts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posts;&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;textless&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with no text;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;createdAt&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oldest&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;The Actor is &lt;a href="https://apify.com/lergassy/truth-social-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Truth Social Scraper&lt;/a&gt;. It bills per post row and per profile row, no start fee; error rows, posts cut by your date or ReTruth filters, duplicates and posts already delivered under an only-new memory are free — which is why raising &lt;code&gt;maxPostsPerUser&lt;/code&gt; to 200 on a schedule is not the expensive mistake it looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; If you need the comments under a post, a search across Truth Social, or trending topics, stop here — none of that is public, and no amount of proxy budget changes it. If you want sentiment on a video-only post, this gives you the file URL and nothing else; the transcription is a separate job. And if you are building a market signal, remember what the 6-second row says: you can have the text fast or the engagement right, and on the first read you do not get both.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>797 of 1,071 TikTok Shop Reviews Came With No Text At All. The 1-Star Ones Talk Twice as Often</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Fri, 06 Nov 2026 21:13:57 +0000</pubDate>
      <link>https://dev.to/matveyg/797-of-1071-tiktok-shop-reviews-came-with-no-text-at-all-the-1-star-ones-talk-twice-as-often-40gm</link>
      <guid>https://dev.to/matveyg/797-of-1071-tiktok-shop-reviews-came-with-no-text-at-all-the-1-star-ones-talk-twice-as-often-40gm</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. Scrapers on Apify are the other half of my week, and one of them does a narrow job: every buyer review of a TikTok Shop product in the US or the UK — stars, text, date, photos, the variant bought, the reviewer.&lt;/p&gt;

&lt;p&gt;The reason people buy review data is text: the complaints, the feature requests, the sentence that explains why 4.5 stars is not 4.9. So before writing another word about "AI review analysis", I counted how much text is actually in there. On the product my own health check hits every night, the answer is a quarter of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two samples, the same 72%
&lt;/h2&gt;

&lt;p&gt;The test product is a US supplement listing, &lt;code&gt;1729448464509734958&lt;/code&gt; — Toplux Magnesium Complex. On 8 October TikTok reported &lt;strong&gt;170,737 reviews&lt;/strong&gt; on it; today it reports &lt;strong&gt;180,293&lt;/strong&gt;, so about 330 arrive a day.&lt;/p&gt;

&lt;p&gt;Sample one: a single run on 8 October, &lt;code&gt;reviewsNewerThan: "3 days"&lt;/code&gt;, cap 5,000. It returned &lt;strong&gt;1,071 reviews in 46 seconds&lt;/strong&gt;, and &lt;code&gt;reviewText&lt;/code&gt; was an empty string on &lt;strong&gt;797&lt;/strong&gt; of them — 74.4%.&lt;/p&gt;

&lt;p&gt;Sample two, in case that was one strange weekend: twelve nightly 100-row runs between 13 October and today, deduplicated by &lt;code&gt;reviewId&lt;/code&gt; into &lt;strong&gt;964 unique reviews&lt;/strong&gt;. Empty text on &lt;strong&gt;686&lt;/strong&gt; — 71.2%.&lt;/p&gt;

&lt;p&gt;Together, &lt;strong&gt;2,035 unique reviews, 1,483 of them with nothing written in them: 72.9%&lt;/strong&gt;. The 274 texts in sample one add up to &lt;strong&gt;3,087 words&lt;/strong&gt;. That is a long blog post, extracted from 1,071 paid rows.&lt;/p&gt;

&lt;p&gt;Nothing is broken: TikTok Shop asks for a star rating in one tap after delivery and treats the text box as optional. But if your pipeline is &lt;code&gt;rows → LLM → themes&lt;/code&gt;, you are paying for four rows to get one sentence, and you should know that going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complaints talk, the praise does not
&lt;/h2&gt;

&lt;p&gt;Here is the part that changes an analysis. Share of reviews carrying any text, by star rating:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stars&lt;/th&gt;
&lt;th&gt;sample 1 (1,071)&lt;/th&gt;
&lt;th&gt;sample 2 (964)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5★&lt;/td&gt;
&lt;td&gt;197 of 848 — &lt;strong&gt;23%&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;186 of 739 — &lt;strong&gt;25%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4★&lt;/td&gt;
&lt;td&gt;20 of 79 — 25%&lt;/td&gt;
&lt;td&gt;16 of 79 — 20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3★&lt;/td&gt;
&lt;td&gt;11 of 45 — 24%&lt;/td&gt;
&lt;td&gt;14 of 38 — 37%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2★&lt;/td&gt;
&lt;td&gt;4 of 20 — 20%&lt;/td&gt;
&lt;td&gt;6 of 17 — 35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1★&lt;/td&gt;
&lt;td&gt;42 of 79 — &lt;strong&gt;53%&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;56 of 91 — &lt;strong&gt;62%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An angry buyer is two to three times more likely to type something than a happy one. The middle of the scale is noisy because the counts are small, but both ends replicate across two samples a month apart.&lt;/p&gt;

&lt;p&gt;The consequence is worth a sticky note. In sample one the mean rating over &lt;strong&gt;all&lt;/strong&gt; 1,071 rows is &lt;strong&gt;4.491&lt;/strong&gt;; over the 274 rows that have text it is &lt;strong&gt;4.190&lt;/strong&gt;. Drop the silent rows — exactly what a text-mining pipeline does — and the product loses &lt;strong&gt;0.3 stars&lt;/strong&gt; before any model sees it.&lt;/p&gt;

&lt;p&gt;So compute the distribution from every row, and read the text knowing it over-represents the 1-star tail by design. If you want complaints and nothing else, ask for them instead of filtering a full export: &lt;code&gt;reviewsFilter: "1"&lt;/code&gt; walks the 1-star filter directly, and on a UK lash-extension product it returned all &lt;strong&gt;26&lt;/strong&gt; one-star reviews in a single API call, &lt;strong&gt;17 with text&lt;/strong&gt; (65% — same pattern, other market).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the text is when there is text
&lt;/h2&gt;

&lt;p&gt;Of the 274 texts in sample one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;65 are three words or fewer.&lt;/strong&gt; Real examples: &lt;code&gt;Great product&lt;/code&gt;, &lt;code&gt;Excelente 👌&lt;/code&gt;, &lt;code&gt;received&lt;/code&gt;, &lt;code&gt;9.5/1&lt;/code&gt;, &lt;code&gt;Muy bueno&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 contain no Latin letters at all&lt;/strong&gt; — &lt;code&gt;👍👍👍👍👍&lt;/code&gt;, &lt;code&gt;♥️♥️&lt;/code&gt;, &lt;code&gt;10/10&lt;/code&gt;, and one Arabic line that translates to "please stop sending me the magnesium".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;45 are 20 words or more&lt;/strong&gt; — the useful tail, which includes an 83-word four-star review admitting the buyer has not tried the product and is rating the other reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;76 are in Spanish.&lt;/strong&gt; There is no language field on the row — TikTok does not send one — so detect it yourself before you average sentiment across a mixed bag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5&lt;/strong&gt; used TikTok's prompted template (&lt;code&gt;Effectiveness: Good&lt;/code&gt;, &lt;code&gt;Ease of Use: Easy&lt;/code&gt;, &lt;code&gt;Packaging: Very good&lt;/code&gt;). I expected these structured lines to dominate; they are 2% of the texts. Don't build a parser for them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And photos are rarer than the Store screenshots suggest: &lt;strong&gt;55 of 1,071 rows&lt;/strong&gt; have at least one image (5%), and &lt;strong&gt;12 of those 55&lt;/strong&gt; have a photo and no text, which is the only case where &lt;code&gt;reviewImages&lt;/code&gt; is the whole review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fields that carried no information, and two that do
&lt;/h2&gt;

&lt;p&gt;Measured over all 2,035 unique rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;verifiedPurchase&lt;/code&gt; was &lt;code&gt;true&lt;/code&gt; on &lt;strong&gt;2,032&lt;/strong&gt;. Three &lt;code&gt;false&lt;/code&gt;. It is not a filter, it is a constant.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;incentivized&lt;/code&gt; was &lt;code&gt;false&lt;/code&gt; on &lt;strong&gt;every single row&lt;/strong&gt;. Don't build a "paid review share" chart on it without checking your own data first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;variant&lt;/code&gt; was &lt;code&gt;"Default"&lt;/code&gt; on all 2,035 — one SKU. On the UK lash product the same field is real (&lt;code&gt;60&amp;amp;80D 10-20mm-3D Effect Lash&lt;/code&gt;) and gives you complaints per variant: the field is only as informative as the listing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two that do work, with a catch each. &lt;code&gt;reviewerId&lt;/code&gt; is a stable numeric ID — 987 distinct reviewers in sample one, 39 of them reviewing twice — but it is &lt;strong&gt;null on 47 rows&lt;/strong&gt; (4.4%), so your group-by needs a fallback bucket. And &lt;code&gt;reviewerName&lt;/code&gt; is masked by TikTok before it reaches me: &lt;code&gt;J**&lt;/code&gt;, &lt;code&gt;A**a&lt;/code&gt;, &lt;code&gt;💋**🧸&lt;/code&gt;. Sample one has 900 distinct masked names for 987 reviewers, and &lt;code&gt;**&lt;/code&gt; appears &lt;strong&gt;8 times&lt;/strong&gt;. Never deduplicate reviewers by name.&lt;/p&gt;

&lt;p&gt;Timestamps, at least, are honest: &lt;code&gt;reviewedAt&lt;/code&gt; is a real millisecond-precision UTC instant on all 1,071 rows, not a "4 months ago" string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling nobody mentions: 5,000 per filter
&lt;/h2&gt;

&lt;p&gt;This is the limit to plan around. One sort order plus one filter stops at about &lt;strong&gt;5,000 reviews&lt;/strong&gt;: page 51 of 100 answers &lt;code&gt;has_more: false&lt;/code&gt;. Asking deeper gets you nothing.&lt;/p&gt;

&lt;p&gt;To go past it, the Actor reads the star breakdown out of the first answer and walks the combinations — each star filter, the other sort order for buckets over 5,000, the photos-only and verified-only filters — dropping duplicate &lt;code&gt;reviewId&lt;/code&gt;s. That is how "25,000 reviews per product" is reached, and 25,000 is the hard cap per run.&lt;/p&gt;

&lt;p&gt;On a product with 180,293 reviews, that is &lt;strong&gt;14% at the absolute best&lt;/strong&gt;, and it is not a random 14%. The honest ways to use this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Newest-first with a date window.&lt;/strong&gt; &lt;code&gt;reviewsNewerThan: "1 day"&lt;/code&gt; on this product returned &lt;strong&gt;351 reviews in 27 seconds&lt;/strong&gt; — a complete, unbiased slice of one day. Schedule it daily and you build the full history going forward instead of fighting for the past.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One star rating at a time&lt;/strong&gt;, when the bucket is small enough to finish (all 26 one-star UK reviews, one call).&lt;/li&gt;
&lt;li&gt;Treat a single giant history dump as a &lt;strong&gt;sample&lt;/strong&gt;, and say so in the report you build on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A smaller listing has no such problem: under 5,000 the first walk gets everything, and the run statistics report TikTok's own total per product so you can check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it gets them, in case you are building your own
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /api/shop/pdp_desktop/get_product_reviews&lt;/code&gt; only answers a request signed by TikTok's own page scripts. Plain HTTP returns &lt;code&gt;{"code":10000}&lt;/code&gt; on every route I tried — direct, residential proxy, a commercial unblocker, the mobile twin of the page — and headless Chromium is refused as well (&lt;code&gt;code 100000&lt;/code&gt;). What works is a &lt;strong&gt;headful&lt;/strong&gt; browser on a virtual display: open one product page of the market, wait for the security scripts, then ask the API for any product ID in that market. &lt;code&gt;page_size: 100&lt;/code&gt; is accepted — &lt;strong&gt;1,071 reviews took 13 calls and one browser launch&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two quirks. An empty page that arrives &lt;em&gt;without&lt;/em&gt; &lt;code&gt;has_more: false&lt;/code&gt; is a hiccup, not the end of the list — ask again and it comes back. And geography is per market: US reviews answer from Apify's own datacenter address, a UK product needs a UK residential one, and a UK product asked from a US page returns zero reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&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;"productUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://shop.tiktok.com/us/pdp/1729448464509734958"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxReviewsPerProduct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsNewerThan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1 day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsSort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"newest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsFilter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&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;A real row from today's run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7705163491157870350"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1729448464509734958"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Toplux Magnesium Complex 8 Essential Magnesium Supplement"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reviewRating"&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;"reviewText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lo acato de enpesar a usted y muy bueno"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-06T20:49:58.096Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"💋**🧸"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reviewerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6979971148214518789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verifiedPurchase"&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;"incentivized"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"skuId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1729448464509800494"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewImageCount"&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;"reviewCountry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scrapedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-06T20:52:22.707Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two stars, text in Spanish that reads like praise, one photo. That row contains most of this article.&lt;/p&gt;

&lt;p&gt;From Python, run it and measure what I have been measuring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~tiktok-shop-reviews-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&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;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;productUrls&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;https://shop.tiktok.com/us/pdp/1729448464509734958&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;maxReviewsPerProduct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviewsNewerThan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# skip free notice rows
&lt;/span&gt;&lt;span class="n"&gt;empty&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="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&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;reviewText&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;print&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;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviewRating&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;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actor is &lt;a href="https://apify.com/lergassy/tiktok-shop-reviews-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;TikTok Shop Reviews Scraper&lt;/a&gt;: &lt;strong&gt;$0.0015 per review&lt;/strong&gt; (down to $0.00105 on higher Apify plans) plus $0.005 per run start, no login, no cookies. Today's control run with "Max cost per run" set to $0.05 delivered &lt;strong&gt;33 reviews in 21.6 seconds and charged $0.04455&lt;/strong&gt;, and 11 of those 33 rows had text — the same share I started with. Notice and error rows are free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; Only US and UK products serve the review list on the web — a link from any other market gets a free notice row explaining that. If you need price, stock, sales or shop stats too, reviews alone will not give them to you; that is a job for &lt;a href="https://apify.com/lergassy/tiktok-shop-scraper" rel="noopener noreferrer"&gt;TikTok Shop Scraper&lt;/a&gt;. There are no seller replies and no Q&amp;amp;A, because the public page has none. And if your deliverable is prose for a model to read, budget by words, not by rows: on this listing, 1,000 paid reviews buy about 2,900 words over 256 texts, 60 of them three words or fewer.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>1,084 Mercado Libre Cards Told Me How Many Were Sold. Between Them They Used Ten Numbers</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:15:46 +0000</pubDate>
      <link>https://dev.to/matveyg/1084-mercado-libre-cards-told-me-how-many-were-sold-between-them-they-used-ten-numbers-56gf</link>
      <guid>https://dev.to/matveyg/1084-mercado-libre-cards-told-me-how-many-were-sold-between-them-they-used-ten-numbers-56gf</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The half of my week that has nothing to do with visas goes into scrapers on Apify, and one of them reads Mercado Libre product cards — the search results page of Latin America's biggest marketplace, which is really 18 separate storefronts on 18 domains and currencies: mercadolibre.com.mx, .com.ar, .cl, .com.co, mercadolivre.com.br and a dozen smaller ones.&lt;/p&gt;

&lt;p&gt;Because the Actor runs a health check on itself every night, I ended up with something more useful than a one-off measurement: &lt;strong&gt;57 successful runs since 9 October, 1,144 product rows&lt;/strong&gt;, and 47 of those runs are the &lt;em&gt;same&lt;/em&gt; query — &lt;code&gt;audifonos bluetooth&lt;/code&gt; on mercadolibre.com.mx, 20 rows a time — spread over &lt;strong&gt;22 distinct days&lt;/strong&gt;. That is a panel, not a snapshot. So this week I pointed the analysis at my own history instead of at the site.&lt;/p&gt;

&lt;p&gt;The field that fell apart first was the one everybody wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;soldQuantity&lt;/code&gt; is a bucket wearing a number's clothes
&lt;/h2&gt;

&lt;p&gt;Units sold is the reason people scrape marketplace search in the first place. Price you can get anywhere; "how many did this seller move" is the signal.&lt;/p&gt;

&lt;p&gt;Across 1,144 rows, &lt;code&gt;soldQuantity&lt;/code&gt; was filled on &lt;strong&gt;1,084&lt;/strong&gt;. Those 1,084 rows contained exactly &lt;strong&gt;ten distinct values&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1000    293 rows        10000   273        100     148
5000     98             50000    81        250000   71
500      53             100000   35        50       21
25       11             (null)   60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole vocabulary. No 1,240. No 37. No 118,000. Mercado Libre's card never prints a sold count — it prints a rung on a ladder: &lt;em&gt;+1000 vendidos&lt;/em&gt;, &lt;em&gt;+250mil vendidos&lt;/em&gt;. My parser reads either that text or the &lt;code&gt;sold_quantity&lt;/code&gt; number in the tracking JSON the page embeds for its own analytics, and &lt;strong&gt;both are already rounded down to the same rungs&lt;/strong&gt;. The rounding is theirs, not mine.&lt;/p&gt;

&lt;p&gt;So "sold" here means "at least". A listing in the &lt;code&gt;1000&lt;/code&gt; bucket sold somewhere between 1,000 and 4,999 units, and the row cannot tell you where.&lt;/p&gt;

&lt;p&gt;It is worse than lossy, because the rungs are not even monotone against the other popularity field on the same card. Grouping every row that had both &lt;code&gt;soldQuantity&lt;/code&gt; and &lt;code&gt;reviewCount&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;review count: min / median / max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;173&lt;/td&gt;
&lt;td&gt;6 / 326 / 8,040&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;432 / 1,709 / 18,262&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;td&gt;1,159 / 5,066 / 69,436&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50000&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;3,252 / &lt;strong&gt;18,676&lt;/strong&gt; / 67,793&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100000&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;24,872 / 68,323 / 69,436&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;250000&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;6,909 / &lt;strong&gt;8,669&lt;/strong&gt; / 79,514&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;250000&lt;/code&gt; bucket has a &lt;em&gt;lower&lt;/em&gt; median review count than the &lt;code&gt;50000&lt;/code&gt; bucket — 8,669 against 18,676. Partly that is cheap generic earbuds that sell in volume and get reviewed rarely; partly it is that sold counts attach to the catalogue entry while reviews attach to whatever listing is winning the buy box today. Either way, if you were planning to rank sellers by &lt;code&gt;soldQuantity&lt;/code&gt;, you are ranking by a ten-value enum with ties everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over three weeks, price moves and the bucket does not
&lt;/h2&gt;

&lt;p&gt;Here is the part that only a panel shows. Of the 157 distinct listings that passed through those 22 days of one search, &lt;strong&gt;26 appeared on five or more days&lt;/strong&gt;. For each of them I diffed price and sold bucket across the whole window:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;price changed at least once on &lt;strong&gt;22 of 26&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;soldQuantity&lt;/code&gt; changed at least once on &lt;strong&gt;2 of 26&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One listing — an authorised-reseller AirPods 5 at MXN 2,999 — was in the top 20 on &lt;strong&gt;21 of the 22 days&lt;/strong&gt;. Its price never moved by a peso. Its bucket went 500 → 1000 once, on day two, and then sat still for three weeks. That single step is the only sales information those 21 rows contain.&lt;/p&gt;

&lt;p&gt;Meanwhile the prices are genuinely alive. A Lenovo RS110 open-ear set ran from MXN 196.42 to 453.37 over 14 days — &lt;strong&gt;2.31x&lt;/strong&gt;. Median swing across the 26 was 1.05x, so most of the movement is a few percent of jitter, but the tail is real and it is where a repricing tool earns its keep. Conclusion for anyone scheduling this kind of scraper: run it daily for price, and treat the sold bucket as a quarterly signal, because that is the resolution it actually has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layouts of one listing, two different prices
&lt;/h2&gt;

&lt;p&gt;While diffing day over day I hit 17 listings (of 340) that appeared sometimes with a &lt;code&gt;variationsText&lt;/code&gt; line — &lt;em&gt;Disponible en 4 colores&lt;/em&gt; — and sometimes without. Mercado Libre renders a colour family as either a single-variant card or a picker card, apparently at random per request.&lt;/p&gt;

&lt;p&gt;For 13 of the 17 the price was the same either way. For one it was not, and it flipped back and forth for three weeks at a fixed position 7 in the results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-20   price 182.71   originalPrice null   variationsText "Disponible en 4 colores"
2026-10-22   price 101.36   originalPrice 192.16 variationsText null
2026-10-23   price 182.65   originalPrice null   variationsText "Disponible en 4 colores"
2026-10-26   price 102.78   originalPrice 194.85 variationsText null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;itemId&lt;/code&gt;, same slot, 1.8x apart, and the discount only exists in one of the two layouts. The picker card quotes the family's base price with no old price; the single-variant card quotes one discounted colour. Neither is wrong, and no field on the row tells you which reality you got — except &lt;code&gt;variationsText&lt;/code&gt; being filled, which is why I now keep that field even though it looks like decoration.&lt;/p&gt;

&lt;p&gt;Which brings me to a bug of my own, found in the same pass. That field read &lt;code&gt;"Disponible en 4 colores4"&lt;/code&gt; on &lt;strong&gt;79 of 247&lt;/strong&gt; rows that had it: the compacted block carries the colour count twice, once in the sentence and once as a screen-reader pill, and &lt;code&gt;.text()&lt;/code&gt; glues them. Same family of mistake as the shipping label I had already de-duplicated months earlier (&lt;code&gt;"Envío gratisEnvío gratis"&lt;/code&gt;). Fixed by dropping a trailing repeat of a number already in the string; control run today, same query, 20 rows: 3 rows with variations, &lt;strong&gt;0 duplicated&lt;/strong&gt;. Build 0.1.47.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positions 1–4 and 13–16 are not results
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;isSponsored&lt;/code&gt; was true on 410 of 1,144 rows — 36%. The placement is not random: of the 160 rows that sat in positions 1, 2 or 3, &lt;strong&gt;155 were ads&lt;/strong&gt;. The counts cluster at positions 1–4 and again at 13–16, which is exactly where Mercado Libre drops its two ad blocks into a 20-card page.&lt;/p&gt;

&lt;p&gt;So an unfiltered top-20 export is roughly a third paid placement, and your "bestsellers for &lt;em&gt;audifonos bluetooth&lt;/em&gt;" list starts with four of them. &lt;code&gt;includeSponsored: false&lt;/code&gt; drops them. I leave the default on, because for ad-intelligence work those rows are the data.&lt;/p&gt;

&lt;p&gt;Two more things the card does not have, measured over the same 1,144 rows: &lt;code&gt;brand&lt;/code&gt; is null on &lt;strong&gt;873&lt;/strong&gt; (76%) and &lt;code&gt;seller&lt;/code&gt; on &lt;strong&gt;421&lt;/strong&gt; — Mercado Libre simply does not print either on most search cards. And 320 rows carried a star rating with &lt;strong&gt;no&lt;/strong&gt; review count, because the compacted review widget shows &lt;code&gt;4.8&lt;/code&gt; alone in some layouts and &lt;code&gt;4.8 (65161)&lt;/code&gt; in others. For a complete record you need the product page, which is a separate, more expensive pass in this Actor for exactly that reason.&lt;/p&gt;

&lt;p&gt;Finally, do not read &lt;code&gt;totalResults&lt;/code&gt; as a count. It was &lt;code&gt;9999&lt;/code&gt; on &lt;strong&gt;1,054 of 1,144&lt;/strong&gt; rows — the ceiling, not a total. Narrow the query and it becomes honest: &lt;code&gt;cable hdmi 8k 3 metros&lt;/code&gt; returned 321 and 322 on two consecutive runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&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;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchTerms"&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;"audifonos bluetooth"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeSponsored"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"relevance"&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;A real row from today's control run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Auriculares Bluetooth Anker Soundcore P40i 2024, color lila, verde oscuro y claro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://articulo.mercadolibre.com.mx/MLM-5259417430-_JM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"itemId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MLM5259417430"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"siteId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MLM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MXN"&lt;/span&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="mf"&gt;799.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"originalPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1499&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"discountPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"installmentsText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3 meses sin intereses de $266.34"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"soldQuantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seller"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Soundcore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isOfficialStore"&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;"freeShipping"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fullShipping"&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;"shippingText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Llega gratis hoy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isSponsored"&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;"position"&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;"page"&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;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"variationsText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domainId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MLM-HEADPHONES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"categoryId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MLM5072"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scrapedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-06T13:13:33.917Z"&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;Note &lt;code&gt;isOfficialStore: true&lt;/code&gt; on a row that is also an ad, and &lt;code&gt;reviewCount: null&lt;/code&gt; sitting next to &lt;code&gt;rating: 4.8&lt;/code&gt; — the two traps from above, both in the first row of an ordinary run.&lt;/p&gt;

&lt;p&gt;From Python, run and read in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~mercadolibre-products-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&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;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;country&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;AR&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;searchTerms&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;zapatillas running&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;condition&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;new&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;sort&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_asc&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;maxItems&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;soldQuantity&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# expect a handful of rungs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actor is &lt;a href="https://apify.com/lergassy/mercadolibre-products-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;MercadoLibre Products Scraper&lt;/a&gt;. It bills per product row with no start fee, and the nightly 20-row run I have been quoting costs &lt;strong&gt;$0.045 and takes 40 to 56 seconds&lt;/strong&gt;, most of which is the headless browser and the residential proxy that search pages need. Reviews, the deals page and official-store pages come from the same Actor over plain HTTP, no proxy, which is why a review row is priced at $0.001 against $0.0025 for a product card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; If you need a real sold count, a stock level or a brand on every row, search cards cannot give it to you — open product pages and pay for the heavier pass. If you need questions and answers, nothing public has them; Mercado Libre shows those only to signed-in users and this Actor never signs in. If you want market share for a category, remember &lt;code&gt;totalResults&lt;/code&gt; caps at 9,999 and the deepest the site will page is a few thousand cards, so you are sampling, not counting. And if you are writing your own: keep concurrency at three. At five and above, Mercado Libre stops answering with products and starts answering with a challenge page, which looks exactly like an empty category until you log the HTML.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>api</category>
      <category>data</category>
    </item>
    <item>
      <title>168 of 169 Paid 'description' Fields Were Identical to the Free 'summary'. The Real Text Was 17x Longer</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:17:19 +0000</pubDate>
      <link>https://dev.to/matveyg/168-of-169-paid-description-fields-were-identical-to-the-free-summary-the-real-text-was-17x-151a</link>
      <guid>https://dev.to/matveyg/168-of-169-paid-description-fields-were-identical-to-the-free-summary-the-real-text-was-17x-151a</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Eventbrite search pages: you hand it a search URL or a city, it pages through the results and returns one row per event — name, dates with the time zone, venue with coordinates, category, tags, image. Switch on the detail pass and it also opens each event page for the ticket price range, the organizer and the description.&lt;/p&gt;

&lt;p&gt;That detail pass costs the buyer a second charge per event. I spent today measuring what it actually buys, on two search pages: Bali, Indonesia and Berlin, Germany, a hundred-event cap each.&lt;/p&gt;

&lt;p&gt;Bali returned 69 events across 4 pages in 28.0 seconds. Berlin returned 100 in 22.2 seconds. Then I diffed two fields that should never be the same — &lt;code&gt;summary&lt;/code&gt;, which comes free from the search listing, and &lt;code&gt;description&lt;/code&gt;, which the buyer pays for.&lt;/p&gt;

&lt;p&gt;They were identical on &lt;strong&gt;168 of 169 rows&lt;/strong&gt;. The 169th had no description at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta tag is not the description
&lt;/h2&gt;

&lt;p&gt;Eventbrite event pages carry JSON-LD, the &lt;code&gt;schema.org/Event&lt;/code&gt; block search engines read. It has &lt;code&gt;offers&lt;/code&gt; with &lt;code&gt;lowPrice&lt;/code&gt; and &lt;code&gt;highPrice&lt;/code&gt;, an &lt;code&gt;organizer&lt;/code&gt;, start and end timestamps with the UTC offset — all genuinely useful, none of it on the search page. It also has a &lt;code&gt;description&lt;/code&gt;. I read that field, shipped it, and called it "full description" in my own documentation.&lt;/p&gt;

&lt;p&gt;It is the meta description. Capped at 140 characters, built from the first sentences of the listing — which is exactly what the search page already hands over as &lt;code&gt;summary&lt;/code&gt;, for free.&lt;/p&gt;

&lt;p&gt;Across those 69 Bali events, &lt;code&gt;description&lt;/code&gt; averaged &lt;strong&gt;108 characters&lt;/strong&gt;. Three rows sat exactly at 140, the cap. The field was not wrong, it was just a duplicate the buyer was paying me to deliver twice.&lt;/p&gt;

&lt;p&gt;The text the organizer actually wrote is in the same HTML I had already downloaded, a few kilobytes further down, inside the Next.js payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;script id="__NEXT_DATA__" type="application&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;json"&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageProps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// [{type:'text', text:'&amp;lt;p&amp;gt;…'}, {type:'image', url:…}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;structuredContent.modules&lt;/code&gt; is the event body as the organizer built it — text blocks, images, the occasional video embed. Joining the text blocks and stripping tags gives the real thing. I refetched all 69 event pages and measured both sources on the same bytes:&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;JSON-LD &lt;code&gt;description&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;structuredContent&lt;/code&gt; text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages that had it&lt;/td&gt;
&lt;td&gt;69 / 69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;69 / 69&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average length&lt;/td&gt;
&lt;td&gt;108 chars&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,841 chars&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12,764&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows where it was longer&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67 of 69&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows with nothing at all&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seventeen times more text, median ratio 7.8x, from a request I was already paying for. The two empty ones are honest: those organizers wrote no body copy, so the Actor falls back to the meta description rather than returning null.&lt;/p&gt;

&lt;p&gt;The control run after the fix, same search, 20 events: &lt;code&gt;description&lt;/code&gt; identical to &lt;code&gt;summary&lt;/code&gt; on &lt;strong&gt;0 of 20&lt;/strong&gt; rows, median length 647, longest 11,838. Prices and currency still filled on 20 of 20 — I did not trade one field for another.&lt;/p&gt;

&lt;p&gt;While I was in there, the same payload gave up eleven more fields that cost nothing extra, because they ride on the page already being fetched: &lt;code&gt;durationMinutes&lt;/code&gt; (filled on 69 of 69), &lt;code&gt;salesStatus&lt;/code&gt;, &lt;code&gt;refundPolicy&lt;/code&gt;, &lt;code&gt;ageRestriction&lt;/code&gt; (9 of 69), &lt;code&gt;doorTime&lt;/code&gt;, &lt;code&gt;parking&lt;/code&gt;, &lt;code&gt;organizerEvents&lt;/code&gt;, &lt;code&gt;organizerFollowers&lt;/code&gt;, &lt;code&gt;organizerVerified&lt;/code&gt;, &lt;code&gt;descriptionBlocks&lt;/code&gt;, and &lt;code&gt;nextSessionAt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;currency&lt;/code&gt; is the organizer's currency, not the country's
&lt;/h2&gt;

&lt;p&gt;Now the finding I cannot fix, only document.&lt;/p&gt;

&lt;p&gt;All 69 Bali rows carry &lt;code&gt;country: "ID"&lt;/code&gt;. Their prices came back in &lt;strong&gt;eight currencies&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;USD 44   AUD 10   GBP 8   EUR 3   SGD 1   MYR 1   NZD 1   HKD 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero in rupiah. A dive trip in North Bali is priced &lt;code&gt;5299.5 HKD&lt;/code&gt;. A free Bitcoin meetup in Canggu is priced &lt;code&gt;0 GBP&lt;/code&gt;. A bracelet workshop in Bali is served from &lt;code&gt;eventbrite.ch&lt;/code&gt; at &lt;code&gt;7.49 EUR&lt;/code&gt;. Those 69 events arrived across eight different Eventbrite domains — &lt;code&gt;.com&lt;/code&gt;, &lt;code&gt;.com.au&lt;/code&gt;, &lt;code&gt;.co.uk&lt;/code&gt;, &lt;code&gt;.sg&lt;/code&gt;, &lt;code&gt;.ch&lt;/code&gt;, &lt;code&gt;.fr&lt;/code&gt;, &lt;code&gt;.ie&lt;/code&gt;, &lt;code&gt;.hk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not a parsing artifact. I checked both independent sources on the page — the JSON-LD &lt;code&gt;priceCurrency&lt;/code&gt; and &lt;code&gt;basicInfo.currency&lt;/code&gt; in the Next.js payload — and they agree on &lt;strong&gt;69 of 69&lt;/strong&gt; rows. Eventbrite publishes the price in the currency of the organizer's account. A yoga teacher who registered in Sydney sells a Canggu sound bath in Australian dollars, and the site never converts.&lt;/p&gt;

&lt;p&gt;Berlin is why nobody notices: 95 of its 100 rows are EUR, because Berlin events are mostly run by Berlin organizers. Bali is a destination — its events are sold by Australians, Brits, Singaporeans — so the same behaviour becomes obvious. If your search covers a tourist region, &lt;strong&gt;sorting those rows by &lt;code&gt;priceMin&lt;/code&gt; sorts by nothing at all&lt;/strong&gt;, and averaging them produces a number with no unit.&lt;/p&gt;

&lt;p&gt;Two more things to distrust in the price fields. &lt;code&gt;priceMin&lt;/code&gt; equals &lt;code&gt;priceMax&lt;/code&gt; on 49 of 69 rows, which usually means one ticket type, not a one-price event. And &lt;code&gt;priceMax&lt;/code&gt; is whatever the most expensive offer on the page is: the Bali Venture Capital World Summit reports &lt;code&gt;168.94&lt;/code&gt; to &lt;code&gt;57662.15&lt;/code&gt; EUR, a 341x spread, because the top of that range is a sponsorship package nobody attends as a guest.&lt;/p&gt;

&lt;h2&gt;
  
  
  A series row is one date out of hundreds
&lt;/h2&gt;

&lt;p&gt;Eventbrite recurring events are a single search result. My rows flag them — &lt;code&gt;isSeries&lt;/code&gt;, &lt;code&gt;seriesId&lt;/code&gt;, &lt;code&gt;occurrencesInSeries&lt;/code&gt; — and on the Bali page &lt;strong&gt;36 of 69 rows were series&lt;/strong&gt;, covering &lt;strong&gt;1,568 dates between them&lt;/strong&gt;. The biggest single row: 421 occurrences. A sound bath studio: 283.&lt;/p&gt;

&lt;p&gt;So "69 events in Bali" and "69 things happening in Bali" are very different counts, and a weekly calendar built from the row's one date will keep rediscovering the same workshop. The new &lt;code&gt;nextSessionAt&lt;/code&gt; confirms what that one date means — on all 8 series rows in my control run it matched &lt;code&gt;startsAt&lt;/code&gt; exactly, i.e. it is the next session on sale. The other 282 are not in the row, and the search page does not have them.&lt;/p&gt;

&lt;p&gt;Berlin again shows the contrast: 7 series rows out of 100, 14 occurrences total.&lt;/p&gt;

&lt;p&gt;Two smaller traps from the same two runs. &lt;code&gt;organizerName&lt;/code&gt; is null on 11 of 69 Bali rows — null in both page sources, because those organizers never set a public name; you get &lt;code&gt;organizerUrl&lt;/code&gt; with a numeric id and nothing else. And one organizer, Eurasia Research, owns &lt;strong&gt;9 of the 69 rows&lt;/strong&gt; with near-identical academic conferences, so an unfiltered "events in Bali" list is 13% one seller. Date range of that list: 6 November 2026 to &lt;strong&gt;29 October 2030&lt;/strong&gt;. Only 40 of 69 fall in the current month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&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;"startUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://www.eventbrite.com/d/indonesia--bali/all-events/"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxEvents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeDetails"&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;A real row from today's run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eventId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1991054633005"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chakras sound sanctuary - SOUND HEALING sessions | Sound bath Bali"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"startsAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-06T14:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Asia/Makassar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isSeries"&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;"occurrencesInSeries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;283&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nextSessionAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-06T14:00:00+08"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"venueName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ADDA YOGA CANGGU - Meditation | Breath | Sound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Kec. Kuta Utara"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-8.643315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;115.1427276&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Religion &amp;amp; Spirituality"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceMin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;29.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priceMax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;29.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AUD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isFree"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"organizerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ADDA YOGA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organizerEvents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organizerFollowers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durationMinutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"refundPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"salesStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sales_ended"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"descriptionBlocks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&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;"salesEndSoon"&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;Note &lt;code&gt;salesStatus: "sales_ended"&lt;/code&gt; on an event the search page listed as upcoming — 3 of 69 Bali rows were sold out or closed. That field did not exist this morning.&lt;/p&gt;

&lt;p&gt;From Python, run and read in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~eventbrite-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&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;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;location&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;Berlin, Germany&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;dates&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;this_weekend&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;maxEvents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&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;events&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events;&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;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;currency&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;currencies&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;The Actor is &lt;a href="https://apify.com/lergassy/eventbrite-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Eventbrite Scraper&lt;/a&gt;. It bills per event row, with a second charge only if you ask for the detail pass; error rows are free and there is no start fee. The detail pass is also what the time goes on: the same Bali search finished in &lt;strong&gt;8.2 seconds&lt;/strong&gt; with details off and &lt;strong&gt;28.0 seconds&lt;/strong&gt; with them on, because that is 69 extra page loads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; There is no search across all of Eventbrite from the API side — you bring a search URL or a city, and Eventbrite's own search decides what exists. Past events are gone; search pages show upcoming only, so a history has to be accumulated by running on a schedule. Attendee lists, ticket inventory and sales numbers are not public and never will be. And one operational detail worth knowing: Eventbrite's WAF answers a flagged search request with &lt;strong&gt;HTTP 405&lt;/strong&gt;, not 403 or 429 — from my own server the search page returns 405 while individual event pages return 200 all day. The Actor climbs a proxy ladder when that happens; if you are writing your own, treat a 405 on a GET as a bot check rather than a method error, which is what I spent an afternoon assuming.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>node</category>
    </item>
    <item>
      <title>677 of 785 Telegram Reaction Chips Counted Zero. The Custom Emoji and the Paid Stars Were Fine</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Thu, 05 Nov 2026 21:19:34 +0000</pubDate>
      <link>https://dev.to/matveyg/677-of-785-telegram-reaction-chips-counted-zero-the-custom-emoji-and-the-paid-stars-were-fine-1pcj</link>
      <guid>https://dev.to/matveyg/677-of-785-telegram-reaction-chips-counted-zero-the-custom-emoji-and-the-paid-stars-were-fine-1pcj</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads public Telegram channels: you hand it a list of usernames, it returns one row per post — text, date, views, reactions, media, forwards, with the channel card on every row. No account, no API key, no phone number. It reads &lt;code&gt;t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt;, the public web preview any visitor sees, 20 posts per request, paging backwards with &lt;code&gt;?before=&amp;lt;message id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I spent today measuring it against 12 channels — 200 posts each, 2,400 posts total, 1 to 4.6 seconds per channel. The headline number is not a feature. It is a bug I shipped and did not notice for six weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to draw a reaction, one of which broke everything
&lt;/h2&gt;

&lt;p&gt;Telegram puts reaction chips under a post. In the HTML they come in three shapes, and I only tested against two of them.&lt;/p&gt;

&lt;p&gt;A custom emoji, the kind channels with a Premium boost use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tgme_reaction"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;tg-emoji&lt;/span&gt; &lt;span class="na"&gt;emoji-id=&lt;/span&gt;&lt;span class="s"&gt;"5265077361648368841"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/tg-emoji&amp;gt;&lt;/span&gt;54.7K&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A paid star reaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tgme_reaction"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;i&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"icon icon-telegram-stars"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/i&amp;gt;&lt;/span&gt;6.3K&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the ordinary one — a plain unicode emoji:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tgme_reaction"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;i&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"emoji"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"background-image:url('...')"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;👍&lt;span class="nt"&gt;&amp;lt;/b&amp;gt;&amp;lt;/i&amp;gt;&lt;/span&gt;101&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My parser read the count by stripping tags from the chip and handing the rest to a number parser anchored at the start of the string. For the first two shapes the emoji element is &lt;strong&gt;empty&lt;/strong&gt;, so stripping leaves &lt;code&gt;54.7K&lt;/code&gt; and &lt;code&gt;6.3K&lt;/code&gt; and everything works. For the third, the emoji is in the HTML &lt;strong&gt;as text&lt;/strong&gt;, inside &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt;. Stripping leaves &lt;code&gt;👍 101&lt;/code&gt;. My parser wanted a digit at position zero, found an emoji, returned null, and I defaulted null to &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the row said &lt;code&gt;{"emoji": "👍", "count": 0}&lt;/code&gt;. The emoji was right. The number was sitting two characters away.&lt;/p&gt;

&lt;p&gt;The fix is to read the tail after the last closed emoji element instead of the whole chip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/(?:&lt;/span&gt;&lt;span class="sr"&gt;i|tg-emoji&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;parseCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I saved one page per channel to disk and parsed the same bytes with both versions, so nothing could drift between runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Chips on the page&lt;/th&gt;
&lt;th&gt;Counted zero before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;reactionsTotal&lt;/code&gt; before → after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bloomberg&lt;/td&gt;
&lt;td&gt;215&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;215&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 → &lt;strong&gt;11,790&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tginfo&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;td&gt;217&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40 → &lt;strong&gt;15,867&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;binance_announcements&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 → &lt;strong&gt;6,311&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bybit_Announcements&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;99 → &lt;strong&gt;1,232&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cointelegraph&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 → &lt;strong&gt;823&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;varlamov&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1 → &lt;strong&gt;2,159&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;durov&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2,483,290 → unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;785&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;677&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Durov's channel is the reason I missed it. His reactions are almost all custom emoji and paid stars — 882 custom and 199 paid out of 1,156 chips — so the one channel in my demo input, the one my daily health check runs, was the one shape that worked. Across all 2,400 posts after the fix: 7,969 chips, &lt;strong&gt;zero&lt;/strong&gt; of them counting zero, and not one chip lost a number it already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;reactionsTotal&lt;/code&gt; still is not
&lt;/h2&gt;

&lt;p&gt;Having the counts back does not make the sum a measure of humans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paid stars are currency, not people.&lt;/strong&gt; 429 of those 7,969 chips are star reactions, and they add up to 8,014,263 — 29% of Durov's entire reaction total. The record holder is &lt;a href="https://t.me/durov/342" rel="noopener noreferrer"&gt;t.me/durov/342&lt;/a&gt;: &lt;code&gt;reactionsTotal&lt;/code&gt; 3,042,500, of which &lt;strong&gt;2,490,000 is a single paid-star chip&lt;/strong&gt;. One wallet can outvote a city. If you are ranking posts by engagement, sum &lt;code&gt;reactions&lt;/code&gt; while skipping entries flagged &lt;code&gt;paid: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom emoji are opaque.&lt;/strong&gt; 1,142 chips come back as &lt;code&gt;custom:5265077361648368841&lt;/code&gt;, because Telegram's web preview ships an id and no picture of the feeling. On Durov's channel that is 67% of the reaction total. You can count them; you cannot say what they mean, and you cannot compare them across channels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero means two different things.&lt;/strong&gt; 5 of my 12 channels — &lt;a class="mentioned-user" href="https://dev.to/telegram"&gt;@telegram&lt;/a&gt;, @meduzalive, @bbcrussian, @whale_alert, @breakingmash — returned &lt;code&gt;reactionsTotal: 0&lt;/code&gt; on all 200 posts, and the markup has no chips at all. Reactions are switched off. That is not "nobody reacted", and if you divide by it you will get a very confident answer about nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;views&lt;/code&gt; is a running counter, and my own history proves it
&lt;/h2&gt;

&lt;p&gt;My health check has been running this Actor on &lt;a class="mentioned-user" href="https://dev.to/durov"&gt;@durov&lt;/a&gt; and &lt;a class="mentioned-user" href="https://dev.to/telegram"&gt;@telegram&lt;/a&gt; daily for a month, which accidentally gave me a longitudinal series on single posts. &lt;a href="https://t.me/durov/552" rel="noopener noreferrer"&gt;t.me/durov/552&lt;/a&gt;, published 11 October 2026, read 28 times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-21  1,250,000 views   29,850 reactions
2026-10-24  1,390,000          31,960
2026-10-28  1,610,000          35,550
2026-11-01  1,820,000          38,180
2026-11-05  2,020,000          43,240
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;+770,000 views in 15 days&lt;/strong&gt; on a post that was already ten days old. &lt;a class="mentioned-user" href="https://dev.to/telegram"&gt;@telegram&lt;/a&gt;'s post 460, published 26 August, went from 1,680,000 on 6 October to 2,340,000 today across 40 reads — +39% on a post nobody was promoting.&lt;/p&gt;

&lt;p&gt;Meanwhile the denominator moves the other way. &lt;a class="mentioned-user" href="https://dev.to/telegram"&gt;@telegram&lt;/a&gt;'s &lt;code&gt;channelSubscribers&lt;/code&gt; in those same 40 reads: 9,390,000 → 9,180,000, falling monotonically, −210,000 in a month. &lt;a class="mentioned-user" href="https://dev.to/durov"&gt;@durov&lt;/a&gt;: 10,300,000 → 10,100,000.&lt;/p&gt;

&lt;p&gt;My row carries &lt;code&gt;viewsPerSubscriber&lt;/code&gt;, which I added because buyers asked for it. For durov/552 it read 0.121 on 21 October and 0.200 today — &lt;strong&gt;+65% with no new behaviour from anyone&lt;/strong&gt;, just a numerator that only rises and a denominator that only falls. The field is only comparable between posts read on the same day.&lt;/p&gt;

&lt;p&gt;The practical consequence is that &lt;strong&gt;sorting by views sorts by age&lt;/strong&gt;. &lt;a class="mentioned-user" href="https://dev.to/telegram"&gt;@telegram&lt;/a&gt;'s 200 most recent posts, ranked by views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8,250,000   2024-08-25   t.me/telegram/329
7,350,000   2024-09-09   t.me/telegram/333
6,390,000   2024-04-29   t.me/telegram/303
...
2,340,000   2026-08-26   t.me/telegram/460   &amp;lt;- the newest post in the sample
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All ten of the top ten are from 2023–2024. Of the top 50 by views, &lt;strong&gt;41 are in the older half of the sample&lt;/strong&gt;. Median views by age on that channel: 1,280,000 for posts 30–90 days old, 2,050,000 at 90–365 days, 3,220,000 past a year. Nothing is broken; views simply never stop accruing. If you want "best post", divide by age or compare posts within a week of each other.&lt;/p&gt;

&lt;p&gt;One more precision trap: Telegram serves views as text like &lt;code&gt;5.88M&lt;/code&gt;, &lt;code&gt;6.3M&lt;/code&gt;, &lt;code&gt;22.3K&lt;/code&gt;. Of 2,393 posts with a view count, 2,152 carry three significant digits, 216 carry two, and 25 carry &lt;strong&gt;one&lt;/strong&gt;. All 399 posts above a million are multiples of 10,000. There were 1,309 distinct view values among 2,393 posts. A post reading 6,300,000 is somewhere in a ±50,000 band, and no scraper can do better, because the number is rounded before it reaches the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;postId&lt;/code&gt; is not a post count — it is a message counter, and albums eat it
&lt;/h2&gt;

&lt;p&gt;Tempting to compute "posts per week" from the id range. Don't. Taking 200 posts per channel and comparing the id span to the number of rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Id span&lt;/th&gt;
&lt;th&gt;Ids missing&lt;/th&gt;
&lt;th&gt;Multi-photo posts (photos)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whale_alert&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bloomberg&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;breakingmash&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;297&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15 (52)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cointelegraph&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;286&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;77 (155)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;varlamov&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;286&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;17 (92)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;durov&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;278&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;6 (45)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of the gap is albums: a grouped set of ten photos is ten messages with ten ids that render as &lt;strong&gt;one&lt;/strong&gt; post. On Cointelegraph, 77 album posts carry 155 photos — roughly 78 swallowed ids against 86 missing. The remainder is deletions. So a gap in &lt;code&gt;postId&lt;/code&gt; tells you nothing on its own, and &lt;code&gt;videoUrl&lt;/code&gt; is worth checking too: 398 of my posts had a video, 386 gave a thumbnail, &lt;strong&gt;335&lt;/strong&gt; gave a direct &lt;code&gt;.mp4&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every failure answers HTTP 200
&lt;/h2&gt;

&lt;p&gt;I probed 18 usernames. All 18 returned 200. Six returned zero posts, for four different reasons — which my Actor used to report with one generic message. It now names them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;coindesk&lt;/code&gt; — 11,198 subscribers, and &lt;code&gt;t.me/coindesk&lt;/code&gt; shows a &lt;strong&gt;"Preview channel"&lt;/strong&gt; button pointing at &lt;code&gt;/s/coindesk&lt;/code&gt;, which 302s straight back to &lt;code&gt;t.me/coindesk&lt;/code&gt;. The page advertises a preview that does not exist.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reuters&lt;/code&gt;, &lt;code&gt;bbcnews&lt;/code&gt; — a bare contact page with a "Send Message" button. Telegram serves byte-identical pages (~9.6 KB) for a real user account and for a name nobody has claimed, so no scraper can tell you which.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tginfochat&lt;/code&gt;, &lt;code&gt;ru_python&lt;/code&gt; — groups, not channels. No preview, ever.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;theblock__&lt;/code&gt;, &lt;code&gt;12345&lt;/code&gt; — t.me redirects these to telegram.org. Probing told me why: a name ending in &lt;code&gt;_&lt;/code&gt; or containing no letter is not a username Telegram accepts. Those are now rejected before the request, as a free error row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth a confession: my own input form suggested &lt;code&gt;https://t.me/s/bbcnews&lt;/code&gt; as an example. That is a contact page. Fixed today, along with the above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&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;"channels"&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;"durov"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://t.me/s/bloomberg"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPostsPerChannel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postsNewerThan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-01"&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;A real row, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bloomberg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://t.me/bloomberg/3225"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-22T03:26:17+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"views"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;64800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reactionsTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;765&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reactions"&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="nl"&gt;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"❤"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"🔥"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;165&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"👍"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bloomberg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channelSubscribers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;186000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viewsPerSubscriber"&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.348&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scrapedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-05T20:52:07.411Z"&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;From Python, run and read in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~telegram-channel-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;channels&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;bloomberg&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;tginfo&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;maxPostsPerChannel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;human&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;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reactions&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;x&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;paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&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;posts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posts;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;human&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid reactions&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;The Actor is &lt;a href="https://apify.com/lergassy/telegram-channel-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Telegram Channel Scraper&lt;/a&gt;. It bills per post row at $0.0002, with no start fee; error rows and posts cut by your date or minimum-views filter are free, which is why I can afford to argue with an input list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; There is no search across all of Telegram — you bring the channel names, and &lt;code&gt;searchQuery&lt;/code&gt; searches &lt;em&gt;inside&lt;/em&gt; a channel, using Telegram's own channel search. Private channels and groups are out, permanently: no web preview, nothing to read. Discussion comments under posts are not in the preview either. And if what you actually want is per-channel reach and posting rhythm rather than raw rows, I keep that in a separate Actor, &lt;a href="https://apify.com/lergassy/telegram-channel-analytics" rel="noopener noreferrer"&gt;Telegram Channel Analytics&lt;/a&gt; — partly because computing it from &lt;code&gt;views&lt;/code&gt; requires knowing everything above, and I would rather do that arithmetic once than explain a 65% jump in engagement that was really just a month passing.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>telegram</category>
      <category>api</category>
      <category>data</category>
    </item>
    <item>
      <title>One Live Palantir Posting Is Dated December 2009. 535 of My 2,207 Lever Jobs Are Over a Year Old</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:14:51 +0000</pubDate>
      <link>https://dev.to/matveyg/one-live-palantir-posting-is-dated-december-2009-535-of-my-2207-lever-jobs-are-over-a-year-old-1k3j</link>
      <guid>https://dev.to/matveyg/one-live-palantir-posting-is-dated-december-2009-535-of-my-2207-lever-jobs-are-over-a-year-old-1k3j</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of the dullest ones reads company job boards: you hand it a list of companies on Lever, it returns one row per open job. No login, no browser — Lever publishes every board as JSON at &lt;code&gt;api.lever.co/v0/postings/&amp;lt;slug&amp;gt;&lt;/code&gt;, and one request returns the whole board with descriptions in it.&lt;/p&gt;

&lt;p&gt;The reason people buy that is the daily feed: pull the boards of 40 companies you care about every morning, keep what is new, push it to Slack or a sheet. Which means everything depends on one field — the posting's date. So I measured it across 12 boards and 2,207 open postings, and the date turned out to be the least trustworthy thing in the row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field, and what it actually is
&lt;/h2&gt;

&lt;p&gt;Lever's posting object carries exactly one timestamp: &lt;code&gt;createdAt&lt;/code&gt;, in milliseconds. There is no &lt;code&gt;updatedAt&lt;/code&gt;, no &lt;code&gt;publishedAt&lt;/code&gt;, no "last refreshed" in the public board API. My Actor passes it through as &lt;code&gt;postedAt&lt;/code&gt;, unchanged — I checked five Spotify rows against the raw board and they match to the millisecond:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-11-04T16:12:53.423Z  ==  2026-11-04T16:12:53.423Z
2026-10-23T21:51:17.203Z  ==  2026-10-23T21:51:17.203Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;postedAt&lt;/code&gt; is not "when this job was advertised". It is &lt;strong&gt;when the requisition record was first created inside the company's Lever account&lt;/strong&gt;. If a company reopens the same req every quarter, or imported its history when it migrated to Lever, that date is archaeology.&lt;/p&gt;

&lt;p&gt;The clearest example on Palantir's board, which has 309 open postings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2009-12-05T00:00:00Z   Forward Deployed Software Engineer   London, United Kingdom
2013-01-31T00:00:00Z   Product Designer                     London, United Kingdom
2013-03-08T00:00:00Z   Deployment Strategist                London, United Kingdom
2014-01-23T00:00:00Z   Forward Deployed Software Engineer   New York, NY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first one is live right now — &lt;code&gt;jobs.lever.co/palantir/5168e8fd-…&lt;/code&gt; answers 200 with a 730 KB page and a working apply button — and it is dated &lt;strong&gt;16.9 years ago&lt;/strong&gt;. Note the timestamps: exactly midnight UTC, four of them. Six of my 2,207 postings have a midnight-exact &lt;code&gt;createdAt&lt;/code&gt;, all from 2009–2019; everything created from 2016 on has a real time-of-day. Midnight is the fingerprint of an import, not of someone pressing Publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution, not the outlier
&lt;/h2&gt;

&lt;p&gt;One ancient row is a curiosity. The distribution is the problem. All 2,207 open postings, age by &lt;code&gt;createdAt&lt;/code&gt;, measured 5 November 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Age&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;created in the last 24 h&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;last 7 days&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;last 30 days&lt;/td&gt;
&lt;td&gt;423&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;older than 180 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;843&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;older than 365 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;535&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median age: &lt;strong&gt;103 days&lt;/strong&gt;. Per board, it is wildly uneven:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Open jobs&lt;/th&gt;
&lt;th&gt;Median age&lt;/th&gt;
&lt;th&gt;Oldest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;veeva&lt;/td&gt;
&lt;td&gt;873&lt;/td&gt;
&lt;td&gt;197 d&lt;/td&gt;
&lt;td&gt;7.5 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shieldai&lt;/td&gt;
&lt;td&gt;655&lt;/td&gt;
&lt;td&gt;57 d&lt;/td&gt;
&lt;td&gt;2.3 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;palantir&lt;/td&gt;
&lt;td&gt;309&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;496 d&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.9 y&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kpler&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;31 d&lt;/td&gt;
&lt;td&gt;7.5 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spotify&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;44 d&lt;/td&gt;
&lt;td&gt;1.0 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;matchgroup&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;56 d&lt;/td&gt;
&lt;td&gt;3.0 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ro&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;177 d&lt;/td&gt;
&lt;td&gt;6.5 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wealthfront&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;86 d&lt;/td&gt;
&lt;td&gt;3.1 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;outreach&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;55 d&lt;/td&gt;
&lt;td&gt;2.1 y&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;neon&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;920 d&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.0 y&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Palantir by creation year: 121 postings from 2026, 65 from 2025, 44 from 2024, 30 from 2022 — 61% of its live board predates this year. Neon's eight postings have a median age of two and a half years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cap hides it, and that is how I missed it for weeks
&lt;/h2&gt;

&lt;p&gt;My Actor sorts a board newest-first before applying &lt;code&gt;maxJobsPerCompany&lt;/code&gt;, which is the right default — if you cap, you want the fresh end. But it means a cap is also a filter on honesty. Same three boards, newest 20 vs the rest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Whole board (median age)&lt;/th&gt;
&lt;th&gt;Newest 20&lt;/th&gt;
&lt;th&gt;Everything after row 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;palantir&lt;/td&gt;
&lt;td&gt;496 d&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13 d&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;505 d&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spotify&lt;/td&gt;
&lt;td&gt;44 d&lt;/td&gt;
&lt;td&gt;12 d&lt;/td&gt;
&lt;td&gt;51 d&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;outreach&lt;/td&gt;
&lt;td&gt;55 d&lt;/td&gt;
&lt;td&gt;45 d&lt;/td&gt;
&lt;td&gt;565 d&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own health check runs the default demo input — spotify, palantir, outreach, 20 per board — and for weeks it reported rows with a median age of 13 to 22 days. The board underneath it is a median of 496 days old. Nothing was broken; I was looking at the top of a sorted list and calling it a market.&lt;/p&gt;

&lt;p&gt;The flip side is worth knowing too: those boards are &lt;em&gt;stable&lt;/em&gt;. Twenty-five runs of those three boards between 23 October and 4 November produced 1,191 job rows and &lt;strong&gt;100 distinct job IDs&lt;/strong&gt; — a board API hands you the same set every time, unlike a relevance-ranked job search, where I have measured consecutive pulls sharing 0 of 25 results. So the ID set is a key you can diff against yesterday's. That is the only honest way to get "new since yesterday" out of Lever: keep your own history of IDs. &lt;code&gt;postedWithin: "day"&lt;/code&gt; filters on &lt;code&gt;createdAt&lt;/code&gt;, and across my sample it would have kept 31 postings out of 2,207 — correct per the field, and not the same question as "what appeared on this board today".&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the slug right is most of the work
&lt;/h2&gt;

&lt;p&gt;There is no directory of Lever boards. You supply the companies, as slugs (&lt;code&gt;spotify&lt;/code&gt;) or careers URLs (&lt;code&gt;https://jobs.lever.co/palantir&lt;/code&gt;). I tried guessing 199 slugs from company names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;19&lt;/strong&gt; answered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;180&lt;/strong&gt; returned HTTP 404 — those companies are on Greenhouse, Ashby, Workable, their own CMS, or use a slug that isn't their name.&lt;/li&gt;
&lt;li&gt;Of the 19, &lt;strong&gt;7 returned &lt;code&gt;200&lt;/code&gt; with an empty array&lt;/strong&gt;: kraken, whoop, trinet, clari, voleon, velocityglobal, carbonhealth. The board exists; nobody is hiring through it. Kraken has open roles — just not here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two failures look identical downstream ("no jobs for this company") and mean opposite things. My Actor separates them: an unreadable slug gets its own free row with &lt;code&gt;type: "error"&lt;/code&gt; saying so, and a run where the filters emptied everything gets a free row naming the filter to widen. Free as in not billed — you pay per job row only. Practical rule: open the company's careers page, click a job, and read the link. &lt;code&gt;lever.co/&amp;lt;slug&amp;gt;&lt;/code&gt; in the URL is your answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fields I would not trust without looking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;employmentType&lt;/code&gt;.&lt;/strong&gt; Lever's underlying field is &lt;code&gt;commitment&lt;/code&gt;, free text the company types. My 2,207 postings contain &lt;strong&gt;21 distinct spellings&lt;/strong&gt;, including &lt;code&gt;Full-Time&lt;/code&gt;, &lt;code&gt;Full Time Employee&lt;/code&gt;, &lt;code&gt;Full-time&lt;/code&gt;, &lt;code&gt;Temp Full-time&lt;/code&gt;, &lt;code&gt;Full-time: Remote&lt;/code&gt;, &lt;code&gt;International EOR&lt;/code&gt;, &lt;code&gt;CLT&lt;/code&gt; and &lt;code&gt;Scholarship&lt;/code&gt;. The Actor lowercases and strips punctuation, which collapses 21 to 18 — &lt;code&gt;fulltime&lt;/code&gt; 1,373 rows, &lt;code&gt;fulltimeemployee&lt;/code&gt; 558. Same thing, two keys. If you filter on equality you will lose 558 of those 1,931 full-time jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Salary.&lt;/strong&gt; Lever has a structured &lt;code&gt;salaryRange&lt;/code&gt;, and 597 of 2,207 postings fill it — 27%, which sounds usable until you see where it comes from: &lt;strong&gt;538 of those 597 are one company&lt;/strong&gt; (Shield AI). Spotify, Palantir, Veeva, Wealthfront, Ro and Neon publish zero structured ranges between them. Publishing a range is a company policy, not a market average, so cross-company salary stats built on this field describe whoever opted in. The Actor's workaround is to read the range out of the posting text when the field is empty and flag which it was: across 100 distinct rows, 12 had &lt;code&gt;salarySource: "board-field"&lt;/code&gt; and &lt;strong&gt;25 had &lt;code&gt;"posting-text"&lt;/code&gt;&lt;/strong&gt;. Two thirds of the salary I got was written in prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;emails&lt;/code&gt;.&lt;/strong&gt; Contact extraction from postings sounds like recruiter leads. On those same 100 rows, 38 carried an email — and all 38 were Palantir, all of them the identical &lt;code&gt;accommodations@palantir.com&lt;/code&gt; from the legal boilerplate at the bottom of every posting. It is a correct extraction of a useless address. Dedupe per company before you call anything a lead.&lt;/p&gt;

&lt;p&gt;One field held up better than I expected: &lt;code&gt;workplaceType&lt;/code&gt;. Lever lets a company leave it unspecified, and my Actor maps unspecified to &lt;code&gt;null&lt;/code&gt; — but &lt;strong&gt;0 of 2,207&lt;/strong&gt; postings were unspecified. All of them say onsite, hybrid or remote. The catch is concentration again: 828 rows say remote and 721 of them are Veeva.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;

&lt;p&gt;Input, as plain as it gets:&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;"companies"&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;"spotify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jobs.lever.co/palantir"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"titleKeywords"&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;"engineer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"locations"&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;"London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stockholm"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxJobsPerCompany"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;A real row, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"job"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lever"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"board"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spotify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Staff Engineer – Identity and Access Management User Platform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stockholm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allLocations"&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;"Stockholm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"London"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"department"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Platform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"employmentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permanent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workplaceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-04T16:12:53.423Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Spotify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jobs.lever.co/spotify/d86e7feb-…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"applyUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jobs.lever.co/spotify/d86e7feb-…/apply"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryMin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"salarySource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&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;Note &lt;code&gt;location&lt;/code&gt; is the first of &lt;code&gt;allLocations&lt;/code&gt;, not the only one — 280 of my 2,207 postings list more than one city (33 of Spotify's 86 do). Location filters match against the whole list, so filtering for London keeps a job whose primary city is Stockholm. That is intentional and worth remembering when you count jobs per city.&lt;/p&gt;

&lt;p&gt;From Python, run and read in one go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apify_api_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/lergassy~lever-jobs-scraper/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;companies&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;spotify&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;outreach&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;maxJobsPerCompany&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&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;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&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;jobs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salarySource&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;with a salary&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;The Actor is here: &lt;a href="https://apify.com/lergassy/lever-jobs-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Lever Jobs Scraper&lt;/a&gt;. It bills per job row, so filters cost you nothing and error rows are free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; There is no keyword search across all of Lever — you bring the company list, and if you don't have one, this Actor cannot find it for you. For market-wide searching I use a different one of mine, &lt;a href="https://apify.com/lergassy/jobs-api" rel="noopener noreferrer"&gt;Jobs API&lt;/a&gt;, which searches Indeed and LinkedIn as well as reading boards. And if your product needs "posted today" to be literally true, no Lever scraper can give you that, mine included: the only timestamp in the source is the day somebody created a database record, and sometimes that was 2009.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>hiring</category>
      <category>data</category>
    </item>
    <item>
      <title>224 'New' Remote Jobs in Twelve Days. Thirty-One Were Posted in the Last 24 Hours</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:13:50 +0000</pubDate>
      <link>https://dev.to/matveyg/224-new-remote-jobs-in-twelve-days-thirty-one-were-posted-in-the-last-24-hours-2an7</link>
      <guid>https://dev.to/matveyg/224-new-remote-jobs-in-twelve-days-thirty-one-were-posted-in-the-last-24-hours-2an7</guid>
      <description>&lt;p&gt;I run a visa agency in Bali, and the other half of my week goes into Apify Actors. One of them reads Indeed's remote listings — the postings the employer actually flagged Remote, not the ones with "remote" in the title. It gets used for the boring, useful thing: a daily feed of fresh remote roles in a category.&lt;/p&gt;

&lt;p&gt;A daily feed sounds like a solved problem. Pull the top 25 per keyword every morning, keep the job IDs you have not seen, email the rest. I had that running against my own Actor as a health check, so I had two weeks of identical runs sitting in storage. When I finally went through them, the "unseen IDs" rule turned out to be measuring something other than new jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runs
&lt;/h2&gt;

&lt;p&gt;Twenty-four runs, 22 October to 4 November, byte-identical input every time:&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;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"customer support"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"software engineer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxJobsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"datePosted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"any"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sortBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"relevance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeContacts"&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;Fifty rows in 23 of the 24 runs — 25 per keyword — and 48 once, when Indeed returned only 23 engineers. Median wall time 6.1 seconds (3.6 to 16.0) for 50 jobs with full descriptions, because Indeed's search endpoint hands over the whole posting in the search response; there is no per-job page fetch to pay for.&lt;/p&gt;

&lt;p&gt;1,198 job rows in total. &lt;strong&gt;373 distinct job IDs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That ratio is the whole article. Twenty-four pulls of "the top 25" produced 373 different jobs, not 25 jobs seen 24 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The top 25 is not a top 25
&lt;/h2&gt;

&lt;p&gt;Here is the overlap between consecutive &lt;code&gt;software engineer&lt;/code&gt; pulls, counting shared job IDs out of 25:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pair&lt;/th&gt;
&lt;th&gt;Gap&lt;/th&gt;
&lt;th&gt;Shared&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;31 Oct 20:11 → 20:32&lt;/td&gt;
&lt;td&gt;21 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25 / 25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Nov 20:10 → 20:32&lt;/td&gt;
&lt;td&gt;22 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25 / 25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Nov 20:10 → 20:13&lt;/td&gt;
&lt;td&gt;3 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25 / 25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Nov 20:12 → 20:18&lt;/td&gt;
&lt;td&gt;6.5 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 Nov 20:12 → 20:14&lt;/td&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;td&gt;22 / 25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same keyword, same filter, same country, minutes apart. Sometimes the identical page; once, 0 of 25 in six and a half minutes. Across all 23 consecutive &lt;code&gt;software engineer&lt;/code&gt; pairs the overlap ranged from 0 to 25, with eight pairs at 6 or fewer.&lt;/p&gt;

&lt;p&gt;Across the whole fortnight, counting how many of the 24 runs each job appeared in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Appeared in&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;exactly 1 run&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;165&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2–3 runs&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4–11 runs&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 or more&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One job survived 22 of 24 runs (a Software Engineer role at Trillium Engineering, posted 26 August). Forty-four percent of everything I ever saw showed up once and never again.&lt;/p&gt;

&lt;p&gt;This is not jobs appearing and expiring. &lt;code&gt;expired&lt;/code&gt; was &lt;code&gt;false&lt;/code&gt; on all 373 rows, and the two near-disjoint runs are not split by freshness in any clean way — the 20:12 set had a median posting age of 98 days, the 20:18 set 40 days, and both contained postings from the same week. What varies is which slice of a much larger result set Indeed's relevance ranking decides to put on page one for a given request. The filter is deterministic; the ordering is not.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;do not treat position in a relevance-sorted job search as a stable key for anything.&lt;/strong&gt; Not "the market shrank", not "this role was pulled", not "we are now ranking above competitor X". Twenty-five is a sample, drawn fresh each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplicating by ID does not give you new jobs
&lt;/h2&gt;

&lt;p&gt;Take one run per day, keep a running set of IDs, count the ones you have never seen — the classic feed rule. Then check how many of those were actually posted in the previous 24 hours:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;New IDs&lt;/th&gt;
&lt;th&gt;Posted in last 24 h&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;23 Oct&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 Oct&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26 Oct&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27 Oct&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28 Oct&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;29 Oct&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Oct&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31 Oct&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 Nov&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Nov&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 Nov&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 Nov&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;224&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eighty-six percent of what a dedupe-by-ID feed would have called "new remote jobs" were postings that already existed and simply had not surfaced yet. If you send that to subscribers daily, the complaint arrives in about a week: &lt;em&gt;I applied to this one already.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix is on the input side, not in your dedupe logic. Ask Indeed for freshness instead of inferring it:&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="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"customer support"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"datePosted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxJobsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;and then still filter on &lt;code&gt;postedAt&lt;/code&gt; yourself, because &lt;code&gt;datePosted&lt;/code&gt; is Indeed's idea of recent, not yours. Keep the ID set as a safety net against duplicates, never as the definition of "new".&lt;/p&gt;

&lt;p&gt;While you are at it, ignore &lt;code&gt;listedAt&lt;/code&gt;. It is Indeed's own indexing timestamp and it does not behave: the gap between &lt;code&gt;postedAt&lt;/code&gt; and &lt;code&gt;listedAt&lt;/code&gt; was exactly zero on 238 of 373 rows, ran up to 2,400 days on the oldest posting, and went &lt;strong&gt;negative&lt;/strong&gt; on three — one row claims it was posted on 2026‑02‑12 and listed on 2025‑03‑19. &lt;code&gt;postedAt&lt;/code&gt; is the date to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Remote" does not mean "location-free"
&lt;/h2&gt;

&lt;p&gt;Every one of the 373 rows carried &lt;code&gt;isRemote: true&lt;/code&gt;, &lt;code&gt;workplaceType: "remote"&lt;/code&gt;, and the string &lt;code&gt;Remote&lt;/code&gt; among Indeed's own attributes. The Remote filter is honest. But &lt;code&gt;location&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What &lt;code&gt;location&lt;/code&gt; contains&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a city, often with a ZIP — &lt;code&gt;Boston, MA&lt;/code&gt;, &lt;code&gt;Salt Lake City, UT&lt;/code&gt;, &lt;code&gt;New Haven, CT 06510&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;196&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the literal string &lt;code&gt;Remote&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;159&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a state or area name — &lt;code&gt;Minnesota&lt;/code&gt;, &lt;code&gt;Puerto Rico&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A remote job with a ZIP code is not a contradiction and it is not a bug in the data. It is the employer saying &lt;em&gt;remote, but hire near this office&lt;/em&gt; or &lt;em&gt;remote, but in this state for tax reasons&lt;/em&gt;. More than half the results were that kind of job. A real row:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6f72db136ac34058"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Software Engineer II, Engineering"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ellevation Education"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Boston, MA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isRemote"&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;"workplaceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"employmentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fulltime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryMin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;125000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryMax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;145000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryPeriod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryEstimated"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-20T05:00:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expired"&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;If your product promises &lt;em&gt;work from anywhere&lt;/em&gt;, filter to &lt;code&gt;location == "Remote"&lt;/code&gt; and accept that you just threw away 57% of the results. If it promises &lt;em&gt;remote roles for people in Ohio&lt;/em&gt;, do the opposite. Either way the decision has to be explicit — &lt;code&gt;isRemote&lt;/code&gt; will not make it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary: two currencies of time in one column
&lt;/h2&gt;

&lt;p&gt;204 of 373 rows carried a salary. The periods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;salaryPeriod&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;year&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hour&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;week&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;month&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Average the midpoints of &lt;code&gt;salaryMin&lt;/code&gt;/&lt;code&gt;salaryMax&lt;/code&gt; without looking at &lt;code&gt;salaryPeriod&lt;/code&gt; and you get &lt;strong&gt;$73,309&lt;/strong&gt;. Annualise first — hourly × 2,080, weekly × 52, monthly × 12 — and the same rows give &lt;strong&gt;$101,956&lt;/strong&gt;, median $95,000. The naive number is not merely wrong, it is wrong in the direction that makes remote work look underpaid, because hourly postings are concentrated in customer support.&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;MULT&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;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;week&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;annual&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="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&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;salaryMin&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="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;salaryMax&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="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;salaryPeriod&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;lo&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;per&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;MULT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;MULT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;per&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more thing in that column: 18 of the 204 values are not round numbers. &lt;code&gt;99591.140625&lt;/code&gt;, &lt;code&gt;85849.921875&lt;/code&gt;, &lt;code&gt;24.040000915527344&lt;/code&gt;. Those are 32-bit floats somewhere upstream of me, round-tripped into JSON. Round them before display — and do not read &lt;code&gt;99591.14&lt;/code&gt; as a precisely negotiated figure.&lt;/p&gt;

&lt;p&gt;A field I expected to matter and did not: &lt;code&gt;salaryEstimated&lt;/code&gt; was &lt;code&gt;false&lt;/code&gt; on all 204 rows. Indeed's own estimate bands did not appear once in this sample, so every salary here came from the employer. Do not build logic that &lt;em&gt;depends&lt;/em&gt; on the estimate being there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Age, and what &lt;code&gt;datePosted: "any"&lt;/code&gt; really means
&lt;/h2&gt;

&lt;p&gt;Median posting age in the sample: &lt;strong&gt;30 days&lt;/strong&gt;. But 86 of 373 were older than 90 days, 25 were older than a year, and the oldest — a Customer Experience Team Member at LuckyGunner.com — was posted on &lt;strong&gt;9 October 2019&lt;/strong&gt;, seven years ago, still not flagged expired, still landing on page one of a relevance search. "Relevance" has no opinion about age unless you give it one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~remote-jobs-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"keywords":["customer support"],"country":"us","datePosted":"day","maxJobsPerQuery":25}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty rows with full descriptions cost $0.04 on the Free plan ($0.0008 per job, cheaper on paid plans) and come back in about six seconds, so the sync endpoint is fine for feed-sized pulls. The Actor is &lt;a href="https://apify.com/lergassy/remote-jobs-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Remote Jobs Scraper&lt;/a&gt;; if you also want on-site and hybrid roles or company career sites, the same engine without the forced Remote filter is &lt;a href="https://apify.com/lergassy/jobs-api?fpr=wmeplu" rel="noopener noreferrer"&gt;Jobs API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;If you need &lt;em&gt;the complete set&lt;/em&gt; of remote jobs for a keyword, no top-N search will give it to you, and my 373-from-24-pulls number is the evidence. Widen &lt;code&gt;maxJobsPerQuery&lt;/code&gt; and split the query by state or job type rather than pulling the same page repeatedly — repetition buys you a random new slice, which is a terrible way to pay for coverage.&lt;/p&gt;

&lt;p&gt;If you need a market-size figure — "how many remote support roles exist in Canada" — this is the wrong instrument entirely. Search result counts on Indeed are not a census, and a 25-row sample redrawn every call is not a trend line.&lt;/p&gt;

&lt;p&gt;Where it does earn its keep is the per-posting layer: full description, apply link, employer website and size, salary as numbers with an explicit period, and the recruiter's email when the posting contains one — 35 of my 373 rows had an email, 9 had a phone — in one request per 25 jobs, with no login and no browser. Just do not ask page one to tell you what changed since yesterday. Ask &lt;code&gt;postedAt&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>One MP4, 27 Runs, 15 Different Transcripts. Lower-Case Them and 26 Are the Same 213 Words</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Wed, 04 Nov 2026 21:12:37 +0000</pubDate>
      <link>https://dev.to/matveyg/one-mp4-27-runs-15-different-transcripts-lower-case-them-and-26-are-the-same-213-words-5a5</link>
      <guid>https://dev.to/matveyg/one-mp4-27-runs-15-different-transcripts-lower-case-them-and-26-are-the-same-213-words-5a5</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them turns video into text — paste a YouTube, TikTok, Instagram or Vimeo link, or a direct MP4, and get the spoken words back with each passage tied to the second it was said.&lt;/p&gt;

&lt;p&gt;Like every Actor I publish, it has a scheduled check: once a day, send one fixed file and make sure data comes out. Boring, cheap, and it has now produced something more useful than the alarm it was built for — a month of identical requests against the same audio, 5 October to 4 November 2026. Twenty-seven runs, one 60.01-second MP4, &lt;code&gt;quality: fast&lt;/code&gt; (Whisper large v3 turbo), language left to auto-detect and detected as English every time.&lt;/p&gt;

&lt;p&gt;Twenty-seven identical requests produced &lt;strong&gt;fifteen different transcript strings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you are building anything that compares transcripts — change detection on a weekly webinar, a cache keyed on output, a regression test that asserts on text — that number is the one that will hurt you. The good news is the shape of the difference, because it is not what it looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved
&lt;/h2&gt;

&lt;p&gt;The file is a passage from &lt;em&gt;Pride and Prejudice&lt;/em&gt; read aloud three times over, which turns out to be an accidentally excellent test fixture: each run transcribes the same two sentences three times, so you get within-run evidence for free.&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;across 27 runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Distinct transcript strings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;wordCount&lt;/code&gt; values&lt;/td&gt;
&lt;td&gt;211, 213, 214, 215, 216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;charCount&lt;/code&gt; values&lt;/td&gt;
&lt;td&gt;1158, 1159, 1160, 1161, 1162, 1163, 1164&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;segmentCount&lt;/code&gt; values&lt;/td&gt;
&lt;td&gt;9, 13, 14, 15, 16, 17, 18, 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;durationSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60.01 — all 27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;language&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;English — all 27&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a build drifting under me. The Actor was rebuilt through the month (0.1.1 up to 0.1.17), but &lt;strong&gt;seven of these runs are the identical build 0.1.17&lt;/strong&gt;, and on their own they produce four different word counts (211, 214, 215, 216) and three different segment counts (9, 13, 18). Nothing in the request pins the sampling either — the recogniser call sends no temperature and no seed, so there is no determinism to be had and no bug to fix.&lt;/p&gt;

&lt;p&gt;Now normalise. Two steps:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&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="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-z0-9&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="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="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 1: drop punctuation and hyphens
&lt;/span&gt;    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;some one&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;someone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 2: one compound word
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 1 alone takes 15 distinct strings down to &lt;strong&gt;8&lt;/strong&gt;. Step 1 plus step 2 takes them to &lt;strong&gt;2&lt;/strong&gt; — and the second of those is a single run. &lt;strong&gt;Twenty-six of the 27 transcripts are byte-identical once you lower-case them and collapse one compound: 213 words, every time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the honest word-error rate on this file is not "15 variants out of 27". It is one run out of 27.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three coin flips
&lt;/h2&gt;

&lt;p&gt;Counting all 81 occurrences (three repeats × 27 runs), the model never changes &lt;em&gt;what&lt;/em&gt; it heard. It changes how it writes it down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Count A&lt;/th&gt;
&lt;th&gt;Count B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;someone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;some one&lt;/code&gt; 43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;little known&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;little-known&lt;/code&gt; 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;neighborhood, this truth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;neighborhood. This truth&lt;/code&gt; 16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And — this is the part I did not expect — &lt;strong&gt;all 27 runs are internally inconsistent&lt;/strong&gt;. Not one run punctuated the three identical repeats the same way. A typical row: "the rightful property of some one or other of their daughters" in the first repeat, "someone" in the second, "some one" again in the third. Same voice, same words, same request, eleven seconds of compute, three different answers inside one string.&lt;/p&gt;

&lt;p&gt;Which explains the word counts completely. The Actor computes them the dumbest possible way, and should:&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wordCount&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;charCount&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;text&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;some one&lt;/code&gt; is two tokens, &lt;code&gt;someone&lt;/code&gt; is one. Three occurrences per file means &lt;code&gt;wordCount&lt;/code&gt; can legally land anywhere in a four-wide band without a single syllable being misheard. The 211 at the bottom of the range is the one run that wrote &lt;code&gt;little-known&lt;/code&gt; twice — a hyphen eating two spaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one run that was actually different
&lt;/h2&gt;

&lt;p&gt;For completeness, the outlier: 20 October, 19 segments, 212 normalised words against everyone else's 213. Two genuine recognition errors, both in the later repeats of the same sentence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repeat two: &lt;code&gt;...must be in want of a wife. However, however little known...&lt;/code&gt; — a doubled word&lt;/li&gt;
&lt;li&gt;repeat three: &lt;code&gt;...in the minds of the surrounding families is considered the rightful property...&lt;/code&gt; — the words &lt;em&gt;that he&lt;/em&gt; are gone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three words wrong out of 213, in 1 of 27 runs. That is the real failure mode to budget for, and it is a different thing from the fourteen cosmetic variants that an exact-match test would also flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in my own pipelines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never diff raw transcripts.&lt;/strong&gt; If your job is "has this recording changed", compare normalised text, not the field as delivered. Raw comparison on this file reports a change 14 times out of 27 and is wrong every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never key a cache or a dedup on transcript equality.&lt;/strong&gt; You will store fifteen copies of one answer. Hash the normalised form, or better, hash the source URL plus &lt;code&gt;durationSeconds&lt;/code&gt; — which was 60.01 on all 27 runs and is the field the billing agrees with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never treat &lt;code&gt;wordCount&lt;/code&gt; as an identity or a volume metric.&lt;/strong&gt; It is a token count over a string whose tokenisation is unstable. For cost, &lt;code&gt;durationMinutes&lt;/code&gt; is the field that matters: all 27 runs billed &lt;code&gt;audio-minute × 1 + file-processed × 1 = $0.011&lt;/code&gt;, because billing is per started minute of audio and the audio never changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In tests, assert with a tolerance.&lt;/strong&gt; Mine now compares normalised word sequences and allows a small edit distance — on this fixture, the threshold that passes all 27 runs and would still catch a real break is three words in 213.&lt;/p&gt;

&lt;p&gt;If you need the spelling of names and jargon to be stable, that is what &lt;code&gt;vocabularyHint&lt;/code&gt; is for — it is the only lever here that actually constrains the output, and it constrains vocabulary, not punctuation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~video-to-text/run-sync-get-dataset-items?token=YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"urls":["https://example.com/clip.mp4"],"quality":"fast","includeSegments":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today's scheduled run of exactly that shape: &lt;strong&gt;10.1 seconds&lt;/strong&gt;, one row, $0.011, on 1 GB of memory. The row carries &lt;code&gt;transcript&lt;/code&gt;, &lt;code&gt;wordCount&lt;/code&gt;, &lt;code&gt;charCount&lt;/code&gt;, &lt;code&gt;segmentCount&lt;/code&gt;, &lt;code&gt;durationSeconds&lt;/code&gt;, &lt;code&gt;language&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, and &lt;code&gt;segments&lt;/code&gt; like these:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"It is a truth universally acknowledged, that a single man in possession of a good fortune,"&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="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.94&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"must be in want of a wife."&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="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"However little known the feelings or views of such a man may be on his first entering"&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;A social video link costs more than a file, and it is worth knowing why before you plan a batch. A 152-second Instagram reel in the same series billed &lt;code&gt;3 audio-minutes + 1 file + 1 video-link = $0.119&lt;/code&gt; and took 20 seconds: the $0.09 is the audio being pulled out of a video page through a residential exit node, and a direct file link never pays it. The Actor is &lt;a href="https://apify.com/lergassy/video-to-text?fpr=wmeplu" rel="noopener noreferrer"&gt;Video to Text&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One error row from the same month explains the charge model better than I can: &lt;code&gt;budget-reached&lt;/code&gt;, &lt;em&gt;"This file would be charged $0.091 but only $0.060 remain within the run budget."&lt;/em&gt; Cap a run with "Max cost per run" and the cap is counted in dollars against the whole row — file plus minutes plus video-link fee — before the download starts. Error rows are free and carry &lt;code&gt;status: "error"&lt;/code&gt;, so check &lt;code&gt;status&lt;/code&gt; before reading &lt;code&gt;transcript&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;If you need a transcript that is byte-stable across runs, no hosted recogniser will give you one — store the audio and the run ID and re-read the row you kept. If you need per-word timings, these segments are phrase-level: nine of them for a minute of continuous reading, in the most common case. And if the words are already sitting in a YouTube caption track, don't pay to re-recognise them. Where this earns its keep is everything with no caption track at all — meetings, lectures, voice notes, downloaded MP4s — at nine-tenths of a cent a minute, with fifteen different strings that all mean the same 213 words.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>api</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Free Press Gave Me 9% of a Paywalled Post. Lenny's Gave 88%. Both Rows Carry the Same Flag</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:20:35 +0000</pubDate>
      <link>https://dev.to/matveyg/the-free-press-gave-me-9-of-a-paywalled-post-lennys-gave-88-both-rows-carry-the-same-flag-di7</link>
      <guid>https://dev.to/matveyg/the-free-press-gave-me-9-of-a-paywalled-post-lennys-gave-88-both-rows-carry-the-same-flag-di7</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads Substack. Substack serves its own archive pages as JSON, so a publication's whole back catalogue is a few HTTP calls away with no browser and no login — which makes it a tempting source for anything content-shaped: competitor tracking, topic research, RAG corpora.&lt;/p&gt;

&lt;p&gt;This week I went to do the obvious thing with it. Pull a few newsletters, pull the body text, and compute something boring like words per like. The arithmetic fell apart on the first sort, and the reason is a single integer field that means two different things depending on who published the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run
&lt;/h2&gt;

&lt;p&gt;Four publications, 25 newest posts each, full text switched on:&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;"publications"&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="s2"&gt;"https://www.lennysnewsletter.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"noahpinion.substack.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://www.astralcodexten.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://www.thefp.com"&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;"maxPostsPerPublication"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeContent"&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;100 post rows, 4 publication rows, 7 m 50 s, charged $0.09025. The split: &lt;strong&gt;51 free posts, 49 paid&lt;/strong&gt;, though that split is wildly uneven by publication — 22 of The Free Press's 25 newest posts are paywalled, against 1 of 25 at Astral Codex Ten.&lt;/p&gt;

&lt;p&gt;Every row carries Substack's own &lt;code&gt;wordCount&lt;/code&gt; (its &lt;code&gt;wordcount&lt;/code&gt; field, verbatim) and, with the switch on, &lt;code&gt;contentHtml&lt;/code&gt; plus a plain-text &lt;code&gt;contentText&lt;/code&gt;. I counted the words in &lt;code&gt;contentText&lt;/code&gt; myself with &lt;code&gt;len(re.findall(r"[A-Za-z0-9'-]+", text))&lt;/code&gt; and divided.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free posts: the number is honest
&lt;/h2&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free posts measured&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median &lt;code&gt;my count / wordCount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.005&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum&lt;/td&gt;
&lt;td&gt;0.981&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum&lt;/td&gt;
&lt;td&gt;1.298&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median of 1.005 on 51 posts is as close to "the same number" as two different tokenisers get. The 1.298 outlier is interesting though, and it is not a scraping failure.&lt;/p&gt;

&lt;p&gt;That post is an Astral Codex Ten book review of Lee Kuan Yew's memoirs: &lt;code&gt;wordCount&lt;/code&gt; 5,683, my count 7,375. Its HTML contains &lt;strong&gt;72 &lt;code&gt;&amp;lt;div class="footnote"&amp;gt;&lt;/code&gt; blocks&lt;/strong&gt;. Strip those and re-count: &lt;strong&gt;5,652&lt;/strong&gt; against a claimed 5,683 — a 0.5% gap, which is just my regex and Substack's disagreeing about hyphens.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;Substack's &lt;code&gt;wordcount&lt;/code&gt; excludes footnotes.&lt;/strong&gt; Seventeen of my 51 free posts read more than 2% long, and sixteen of those were footnote-carrying Astral Codex Ten and Noahpinion pieces. "Storm Of Steel" (36 footnotes): 7,559 claimed, 8,625 counted, 7,551 with footnotes removed. "Why Donald Trump won in 2024" (34 footnotes): 12,818 claimed, 13,454 counted, 12,769 without. If you are building a reading-time estimate or chunking for RAG, that is a real 8–30% of the text living outside the official length.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paid posts: the number describes a document you don't have
&lt;/h2&gt;

&lt;p&gt;Paid posts come back with every piece of metadata intact — title, date, likes, comments, restacks, &lt;code&gt;wordCount&lt;/code&gt; — and the body you get is the publisher's free preview, correctly flagged as &lt;code&gt;contentIsPreview: true&lt;/code&gt; and, sensibly, not charged for.&lt;/p&gt;

&lt;p&gt;What no field tells you is &lt;strong&gt;how much of the post that preview is&lt;/strong&gt;. On my 49 paid posts:&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median &lt;code&gt;my count / wordCount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.344&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Words &lt;code&gt;wordCount&lt;/code&gt; promised, in total&lt;/td&gt;
&lt;td&gt;134,493&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Words actually delivered&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65,845&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Half the corpus, and the halving is not uniform. Grouped by publication it stops looking like a platform behaviour and starts looking like an editorial decision:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Publication&lt;/th&gt;
&lt;th&gt;paid posts&lt;/th&gt;
&lt;th&gt;median share delivered&lt;/th&gt;
&lt;th&gt;range&lt;/th&gt;
&lt;th&gt;claimed → delivered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lenny's Newsletter&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70.2%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;52.5–87.5%&lt;/td&gt;
&lt;td&gt;74,441 → 51,857&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noahpinion&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20.3–42.1%&lt;/td&gt;
&lt;td&gt;25,420 → 8,367&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Free Press&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.7%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.0–76.8%&lt;/td&gt;
&lt;td&gt;34,612 → 5,621&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Astral Codex Ten&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;20 → 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Concretely: &lt;em&gt;How to turn your AI into a world-class design partner&lt;/em&gt; claims 4,604 words at Lenny's and handed me 4,028 of them. &lt;em&gt;Meet the 'Election Defenders' Preparing for a Contested Election&lt;/em&gt; claims 1,751 words at The Free Press and handed me &lt;strong&gt;167&lt;/strong&gt;. Both rows say &lt;code&gt;isPaid: true&lt;/code&gt;, &lt;code&gt;contentIsPreview: true&lt;/code&gt;. Nothing in the data distinguishes "we show you most of it" from "we show you the first paragraph."&lt;/p&gt;

&lt;p&gt;Two things make this easy to miss:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The previews do not look truncated.&lt;/strong&gt; Only &lt;strong&gt;2 of 49&lt;/strong&gt; paid bodies end in an ellipsis, and both are Free Press posts. Lenny's previews just stop — my sample's cut on &lt;em&gt;How to tell if a startup has a moat&lt;/em&gt; lands mid-argument on "...but many still fail to become good businesses." If your pipeline's heuristic for "is this complete" is a trailing &lt;code&gt;…&lt;/code&gt;, it will pass 47 of 49 truncated documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;contentIsPreview&lt;/code&gt; is not a paywall flag.&lt;/strong&gt; One paid post, Astral Codex Ten's &lt;em&gt;Hidden Open Thread 454.5&lt;/em&gt;, returned &lt;code&gt;contentHtml: null&lt;/code&gt; — nothing at all. That makes &lt;code&gt;contentIsPreview&lt;/code&gt; &lt;strong&gt;false&lt;/strong&gt;, because there is no preview to flag. Filter on &lt;code&gt;isPaid&lt;/code&gt;, not on the preview flag, or that row quietly joins your "full text" bucket with an empty body.&lt;/p&gt;

&lt;p&gt;The practical rule I now use: &lt;code&gt;wordCount&lt;/code&gt; is a property of the &lt;em&gt;post&lt;/em&gt;; &lt;code&gt;contentText&lt;/code&gt; is a property of &lt;em&gt;what Substack is willing to show an anonymous reader&lt;/em&gt;. Never divide one by the other across a mixed free/paid sample, and never put paid bodies in the same corpus as free ones without recording the share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="n"&gt;WORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z0-9&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&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;WORD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;contentText&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="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wordCount&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;r&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;wordCount&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="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;textShare&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;share&lt;/span&gt;          &lt;span class="c1"&gt;# 1.0-ish = whole post; 0.09 = first paragraph
&lt;/span&gt;    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usableText&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isPaid&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="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two smaller traps in the same dataset
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The publication row's &lt;code&gt;url&lt;/code&gt; is whatever you typed.&lt;/strong&gt; I passed &lt;code&gt;noahpinion.substack.com&lt;/code&gt;; the publication row came back with &lt;code&gt;url: "https://noahpinion.substack.com"&lt;/code&gt; while all 25 of its post URLs are on &lt;code&gt;www.noahpinion.blog&lt;/code&gt;. Join posts to publications on the &lt;code&gt;publication&lt;/code&gt; name or on &lt;code&gt;customDomain&lt;/code&gt;, never on the host of &lt;code&gt;url&lt;/code&gt;. (The Free Press is the same trap from the other end: its &lt;code&gt;subdomain&lt;/code&gt; is &lt;code&gt;bariweiss&lt;/code&gt;, not &lt;code&gt;thefp&lt;/code&gt; — the brand was renamed, the subdomain was not.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;subscriberTier&lt;/code&gt; is empty more often than you'd guess.&lt;/strong&gt; Substack publishes a band, not a count — "Millions of subscribers" for Lenny's, "Hundreds of thousands" for Noahpinion — and &lt;code&gt;subscribersOrderOfMagnitude&lt;/code&gt; turns that into 1000000 and 100000 respectively. But Astral Codex Ten and The Free Press returned &lt;strong&gt;null&lt;/strong&gt; for both fields. Two of four. If you are sizing a niche, expect a coverage hole, not a number for every row; &lt;code&gt;plans&lt;/code&gt; was filled for all four ($10/month and $100/year at three of them, $20/$200 at Lenny's).&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~substack-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"publications":["noahpinion.substack.com"],"maxPostsPerPublication":2}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exact call, this morning: &lt;strong&gt;22 seconds&lt;/strong&gt;, 4 rows, &lt;code&gt;publication ×1 + post ×2 = $0.0029&lt;/code&gt;. The last row has &lt;code&gt;type: "notice"&lt;/code&gt; and is a free plain-English note about what you were charged — it is not data, filter it out. The Actor is &lt;a href="https://apify.com/lergassy/substack-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Substack Scraper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One operational note from the logs: a single request to &lt;code&gt;noahpinion.substack.com&lt;/code&gt; died with &lt;code&gt;tls handshake eof&lt;/code&gt; and succeeded on retry from a different address. &lt;code&gt;*.substack.com&lt;/code&gt; is pickier than the custom domains — it answers 403 to a Chrome-impersonating client from every address I have, while a plain client gets a 200 and the redirect. If you write your own Substack client and it works on your laptop but 403s in CI, that asymmetry is what you are hitting, and the fix is less browser-like, not more.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;If you need the &lt;em&gt;body&lt;/em&gt; of paid posts, nothing here helps and nothing should — the median paid preview in my sample was a third of the article, and no proxy changes that. If you need subscriber counts rather than bands, Substack does not publish them. And if you want comments or Notes, this reads posts and publications only. Where it does earn its keep is the metadata layer: 100 posts across four publications with likes, restacks, word counts, authors, tags and paywall status, in under eight minutes for nine cents — the archive endpoint hands all of that over in one call per 20 posts, whether you ask for the text or not.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>I Averaged All 31 Reviews 2GIS Handed Me and Got 4.36. The Page Says 4.6</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Wed, 04 Nov 2026 05:14:20 +0000</pubDate>
      <link>https://dev.to/matveyg/i-averaged-all-31-reviews-2gis-handed-me-and-got-436-the-page-says-46-3hac</link>
      <guid>https://dev.to/matveyg/i-averaged-all-31-reviews-2gis-handed-me-and-got-436-the-page-says-46-3hac</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads 2GIS — the business directory that covers Russia, Kazakhstan, the UAE, Uzbekistan, Czechia, Chile and a dozen more countries, and that most Western scraping stacks have never heard of. It is a Google Maps substitute in places where Google Maps is thin, and unlike Maps it hands out e-mails.&lt;/p&gt;

&lt;p&gt;This week I went to do the boring thing everybody does with review data: pull a category, pull the reviews, recompute the average, see who is really good. It did not reconcile, and the reason is a field pair that looks redundant and is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two counters
&lt;/h2&gt;

&lt;p&gt;Every place row comes back with both of these:&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="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ratingCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reviewCount&lt;/code&gt; is 2GIS's &lt;code&gt;general_review_count&lt;/code&gt;. &lt;code&gt;ratingCount&lt;/code&gt; is &lt;code&gt;general_review_count_with_stars&lt;/code&gt;. The second one is always the larger of the two, because on 2GIS you can tap stars without writing anything — and the reviews feed only serves the ones with text.&lt;/p&gt;

&lt;p&gt;On a 40-row pull of &lt;code&gt;кофейня&lt;/code&gt; in Novosibirsk (438 results exist; I took the first 40):&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Places where &lt;code&gt;ratingCount&lt;/code&gt; &amp;gt; &lt;code&gt;reviewCount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40 of 40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sum of &lt;code&gt;reviewCount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;34,509&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sum of &lt;code&gt;ratingCount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;72,030&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readable share of the vote base&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median readable share per place&lt;/td&gt;
&lt;td&gt;61.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst place&lt;/td&gt;
&lt;td&gt;19.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best place&lt;/td&gt;
&lt;td&gt;87.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So across this sample, slightly more than half of the opinions that move a 2GIS star rating have no text attached and are not in any feed you can page through. That is not a scraper limitation I can fix with a better proxy; the endpoint does not carry them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the arithmetic stopped working
&lt;/h2&gt;

&lt;p&gt;To check whether the readable half is at least a fair sample, I took the smallest place in the set — &lt;em&gt;Breakfast house&lt;/em&gt;, Красный проспект 31, 30 text reviews against 41 star votes — and pulled every review it has.&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;"placeUrls"&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;"70000001117226436"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"includeReviews"&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;"maxReviewsPerPlace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&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;31 reviews came back (the counter said 30 — it lags; one arrived that morning). All 31 had text. 26 of 31 carried an owner reply, 18 carried photos, and they spanned 25 August to 4 November 2026.&lt;/p&gt;

&lt;p&gt;The star distribution of those 31: &lt;strong&gt;24 fives, 1 four, 1 three, 3 twos, 2 ones.&lt;/strong&gt; Sum 135, mean &lt;strong&gt;4.355&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The page says &lt;strong&gt;4.6&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Before blaming the ten invisible voters, I checked whether they &lt;em&gt;could&lt;/em&gt; close the gap. Give every one of the ten unseen votes a perfect 5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(135 + 50) / 41 = 4.512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still not 4.6. The displayed rating is not the arithmetic mean of all 41 votes under any assignment of the ten I cannot see — the maximum reachable value is 4.51. 2GIS is weighting something: recency, reviewer history, something it does not publish. I cannot tell you which, and neither can anyone reading the API. What I can tell you is that it is settled, not a rounding artefact.&lt;/p&gt;

&lt;p&gt;Practical consequence: &lt;strong&gt;do not recompute the rating from scraped reviews and present it as the rating.&lt;/strong&gt; They are two different numbers measuring overlapping but unequal populations, and on a real place they came out 0.25 stars apart in my sample — in the direction that makes the business look worse than its page does.&lt;/p&gt;

&lt;p&gt;If you want a defensible metric from review text, compute it and name it for what it is — "mean of 31 readable reviews" — and keep &lt;code&gt;rating&lt;/code&gt; beside it as the platform's own figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other number that is not about this place
&lt;/h2&gt;

&lt;p&gt;Chains make the same mistake available in a second flavour. Every row carries &lt;code&gt;orgRating&lt;/code&gt; and &lt;code&gt;orgBranchCount&lt;/code&gt; next to &lt;code&gt;rating&lt;/code&gt;, and &lt;code&gt;orgRating&lt;/code&gt; is the brand's score across all its branches.&lt;/p&gt;

&lt;p&gt;Of my 40 coffee shops, &lt;strong&gt;24 were branches of a multi-location brand&lt;/strong&gt;. In 21 of those 24 the brand score was higher than the branch's own, mean gap +0.29, worst case +0.9: &lt;em&gt;Куликов&lt;/em&gt; on улица Кирова sits at &lt;strong&gt;3.8&lt;/strong&gt; with 1,002 reviews while the 41-branch brand averages &lt;strong&gt;4.7&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The sample also contained the same brand several times over, which makes it very visible:&lt;/p&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;branch ratings in the sample&lt;/th&gt;
&lt;th&gt;&lt;code&gt;orgRating&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Академия Кофе&lt;/td&gt;
&lt;td&gt;4.6, 4.5, 4.5, 4.5&lt;/td&gt;
&lt;td&gt;4.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skuratov Coffee&lt;/td&gt;
&lt;td&gt;4.5, 5.0, 5.0, 4.7&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brewsell&lt;/td&gt;
&lt;td&gt;4.1, 4.4, 4.6&lt;/td&gt;
&lt;td&gt;4.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Чашка кофе&lt;/td&gt;
&lt;td&gt;3.4, 3.8&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pick the wrong column and every branch of Чашка кофе scores 4.3, including the one the neighbourhood rates 3.4. Sort a lead list by &lt;code&gt;orgRating&lt;/code&gt; and you have sorted brands, not locations.&lt;/p&gt;

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

&lt;p&gt;The Actor is plain HTTP against the two public JSON APIs the 2GIS web app uses — no browser, no login, no API key of your own. &lt;a href="https://apify.com/lergassy/2gis-places-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;2GIS Places Scraper&lt;/a&gt;, one row per place, reviews as an optional second row type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~2gis-places-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"queries":["кофейня"],"cities":["Новосибирск"],"maxPlacesPerQuery":2,"includeReviews":true,"maxReviewsPerPlace":3}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exact call, this morning: 8 rows in &lt;strong&gt;6.6 s&lt;/strong&gt;, charged &lt;code&gt;place ×2&lt;/code&gt; and &lt;code&gt;review ×6&lt;/code&gt; = $0.0082.&lt;/p&gt;

&lt;p&gt;Reading it back, with the two traps handled:&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# the free summary row
&lt;/span&gt;&lt;span class="n"&gt;places&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;places&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;readable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviewCount&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;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ratingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;# how much of the rating you can read
&lt;/span&gt;    &lt;span class="n"&gt;mine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;placeId&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;p&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&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;readable&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="nf"&gt;round&lt;/span&gt;&lt;span class="p"&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;mine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;mine&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="n"&gt;mine&lt;/span&gt; &lt;span class="k"&gt;else&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;chain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orgBranchCount&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;1&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;1&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;solo&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;Three small things that bite if you skip them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The last row of every run has &lt;code&gt;type: "notice"&lt;/code&gt; and is a free plain-English summary of what you were charged. It is not data. Filter it.&lt;/li&gt;
&lt;li&gt;Reviews are a separate row type in the same dataset, joined by &lt;code&gt;placeId&lt;/code&gt;, not nested inside the place. Export to CSV without splitting by &lt;code&gt;type&lt;/code&gt; and you get a sheet with two different column sets stacked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emails&lt;/code&gt; was filled on 18 of my 40 places, &lt;code&gt;phones&lt;/code&gt; on 36, &lt;code&gt;websites&lt;/code&gt; on 34, &lt;code&gt;socials&lt;/code&gt; on 36. For contact data that is a good hit rate by directory standards, but it is a directory, not a verifier — the e-mail is whatever the business typed into its listing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One thing that genuinely needs a proxy
&lt;/h2&gt;

&lt;p&gt;While writing this I fired the same reviews endpoint straight from a plain datacenter host with &lt;code&gt;curl&lt;/code&gt;, to grab a raw response for the article. It never answered — &lt;strong&gt;still hanging after 120 seconds&lt;/strong&gt;, when the identical request through the Actor on Apify's datacenter proxy had returned in under seven. The catalog API does this too; it is why the Actor retries twice and then falls back to a residential address instead of simply timing out. If you build your own 2GIS client and it "works on my laptop, hangs in CI", that is the thing you are hitting, not rate limiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;2GIS is organised by city, not by country — &lt;code&gt;Москва&lt;/code&gt;, &lt;code&gt;Алматы&lt;/code&gt;, &lt;code&gt;Dubai&lt;/code&gt; work, &lt;code&gt;Russia&lt;/code&gt; does not, and an uncovered city comes back as one free error row. Reviews are $1 per 1,000 on top of $1.50 per 1,000 places, so turning them on across a 5,000-place sweep is a real bill; sample instead. And if the number you actually need is the vote count behind the star rating rather than the readable reviews, take &lt;code&gt;ratingCount&lt;/code&gt; and stop — the other 52% is not coming.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>Nine of Twelve Outbound Links Came From Accounts Pinterest Made Itself. Their Profiles Report 1 Monthly Views</title>
      <dc:creator>Matvey</dc:creator>
      <pubDate>Tue, 03 Nov 2026 21:16:49 +0000</pubDate>
      <link>https://dev.to/matveyg/nine-of-twelve-outbound-links-came-from-accounts-pinterest-made-itself-their-profiles-report-1-2i4p</link>
      <guid>https://dev.to/matveyg/nine-of-twelve-outbound-links-came-from-accounts-pinterest-made-itself-their-profiles-report-1-2i4p</guid>
      <description>&lt;p&gt;I run a visa agency in Bali, and the other half of my week goes into Apify Actors. The Pinterest one answers a question marketing people ask constantly: for this niche, which websites is Pinterest actually sending clicks to? One run, group the rows by &lt;code&gt;domain&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;On 3 November 2026 I ran it on &lt;code&gt;minimalist kitchen&lt;/code&gt; and the answer came back as a list of websites I had never heard of — pinned by accounts with zero followers, zero pins and a name that starts with &lt;code&gt;preview_&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run
&lt;/h2&gt;

&lt;p&gt;Pinterest search, pins scope, 20 pins, engagement switch on, 3 November 2026, no login. &lt;a href="https://apify.com/lergassy/pinterest-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Pinterest Scraper&lt;/a&gt; on a plain Apify account.&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rows&lt;/td&gt;
&lt;td&gt;20 of 20 asked for, zero errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run time&lt;/td&gt;
&lt;td&gt;10.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pins with an outbound link&lt;/td&gt;
&lt;td&gt;12 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…of those, pinned by a &lt;code&gt;preview_&lt;/code&gt; account&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9 of 12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinners with a &lt;code&gt;preview_&lt;/code&gt; username&lt;/td&gt;
&lt;td&gt;9 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;title&lt;/code&gt; filled&lt;/td&gt;
&lt;td&gt;18 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;saves&lt;/code&gt; filled&lt;/td&gt;
&lt;td&gt;0 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;reactions&lt;/code&gt; filled&lt;/td&gt;
&lt;td&gt;2 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charged&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pin ×20&lt;/code&gt; = $0.03, &lt;code&gt;pin-details ×0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine of the twenty results were pinned by &lt;code&gt;preview_roomhint_com&lt;/code&gt;, &lt;code&gt;preview_linbiepham_com&lt;/code&gt;, &lt;code&gt;preview_miscaluxe_com&lt;/code&gt;, &lt;code&gt;preview_elodiestyling_com&lt;/code&gt;, &lt;code&gt;preview_prettynestdecor_de&lt;/code&gt;, &lt;code&gt;preview_wishonbrithday_com&lt;/code&gt;, &lt;code&gt;preview_38f117427e82883f95d280&lt;/code&gt; and &lt;code&gt;preview_bd3b790782095ce502293e&lt;/code&gt;. Every single one of those nine pins carried a title and an outbound link. The eleven ordinary accounts in the same twenty produced three links between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a preview account looks like from the profile endpoint
&lt;/h2&gt;

&lt;p&gt;So I fed six of those usernames back in as profile inputs. Six profile rows, 2.1 s, $0.012:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;all six&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Pinterest Preview &amp;lt;the username's tail&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;followers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0, 0, 0, 0, 2, 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pinCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0&lt;/strong&gt; on five of six (26 on the sixth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;boardCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;websiteUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;monthlyViews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;imageUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s.pinimg.com/images/user/default_280.png&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15–21 October 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in there are worth stopping on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;monthlyViews&lt;/code&gt; is &lt;code&gt;−1&lt;/code&gt;, on all six. The field comes from Pinterest's &lt;code&gt;profile_reach&lt;/code&gt;, and &lt;code&gt;−1&lt;/code&gt; is not a number of views — it is a sentinel meaning "not computed for this account". If you average that column across a sample, every preview account drags your mean down by one view and silently turns into a real data point. I treat anything below zero as missing now, and so should any aggregation you write on top of these rows.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;websiteUrl&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; — on accounts whose entire purpose is to represent a website. The domain is in the username (&lt;code&gt;preview_miscaluxe_com&lt;/code&gt; → &lt;code&gt;miscaluxe.com&lt;/code&gt;) and in each pin's &lt;code&gt;link&lt;/code&gt;, and nowhere in the field named for it. Two of the nine are named with hex instead of a domain (&lt;code&gt;preview_bd3b790782095ce502293e&lt;/code&gt;), and those still link out to ordinary sites — &lt;code&gt;homestorageorganizer.com&lt;/code&gt; and &lt;code&gt;cozykitchenandliving.com&lt;/code&gt; — so the hex is an opaque id for a domain, not the absence of one.&lt;/p&gt;

&lt;p&gt;These are not dead accounts, either. I checked every domain the nine pins pointed at: &lt;code&gt;roomhint.com&lt;/code&gt;, &lt;code&gt;linbiepham.com&lt;/code&gt;, &lt;code&gt;elodiestyling.com&lt;/code&gt;, &lt;code&gt;prettynestdecor.de&lt;/code&gt;, &lt;code&gt;miscaluxe.com&lt;/code&gt;, &lt;code&gt;wishonbrithday.com&lt;/code&gt;, &lt;code&gt;cozykitchenandliving.com&lt;/code&gt; — &lt;strong&gt;7 of 7 answered 200&lt;/strong&gt; with real titles ("RoomHint | Storage, Organization &amp;amp; Room Ideas That Work", "Misca Luxe: Bathroom Fixes &amp;amp; Kitchen Cabinet Ideas"). Small content sites, all created-or-indexed in the last few weeks, all getting distribution through an account they appear not to have had to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Their pins only exist in search
&lt;/h2&gt;

&lt;p&gt;Here is the part that breaks the obvious next step. Having found nine interesting publishers, the natural move is to walk each profile and see everything they pin. All six preview profiles returned &lt;strong&gt;zero pins&lt;/strong&gt;. The profile feed is Pinterest's &lt;code&gt;_created&lt;/code&gt; tab, and for these accounts it is empty — consistent with the &lt;code&gt;pinCount: 0&lt;/code&gt; in the row, and completely inconsistent with the pins I was holding in my hand from the same run.&lt;/p&gt;

&lt;p&gt;Their board is no help either. The &lt;code&gt;boardUrl&lt;/code&gt; on those nine pins is &lt;code&gt;/preview_&amp;lt;domain&amp;gt;/_inspo/&lt;/code&gt; — one board, named &lt;code&gt;_inspo&lt;/code&gt;, and Pinterest's board endpoint does not serve it. Pass that URL in and the scraper reads it as a profile instead, because a path segment starting with &lt;code&gt;_&lt;/code&gt; is a profile tab (&lt;code&gt;/_created/&lt;/code&gt;, &lt;code&gt;/_saved/&lt;/code&gt;), not a board slug. Across 60 pins from three keywords, &lt;strong&gt;13 pointed at an underscore board&lt;/strong&gt;: 11 &lt;code&gt;_inspo&lt;/code&gt; and 2 &lt;code&gt;_quick_saves&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the only place these pins are reachable is the search index. For a traffic-sources report that is fine — search is where you were looking anyway. For "show me everything this competitor publishes" it is a dead end, and the dead end is not in my code.&lt;/p&gt;

&lt;p&gt;What it changes practically: &lt;strong&gt;don't filter a Pinterest sample by follower count.&lt;/strong&gt; The obvious cleanup — drop pinners under 10 followers as noise — would have deleted three quarters of the link data on this keyword. The accounts holding the links are supposed to have no followers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entry point decides the ID format, and the bill
&lt;/h2&gt;

&lt;p&gt;The same run also settled something I had been guessing at. I pulled one account two ways at once — its board, and its profile — 20 pins from each:&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;board feed&lt;/th&gt;
&lt;th&gt;profile feed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pins&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numeric &lt;code&gt;pinId&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20 of 20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engagement lookups charged&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pinterest has been moving logged-out pin pages to opaque tokens — &lt;code&gt;e1_AfqPShxPH41hAo83UO7dgaAaDCq5sJaNsLtv3S…&lt;/code&gt;, 55-plus characters — and its own pin endpoint answers &lt;code&gt;Pin not found&lt;/code&gt; on them. I measured that on search results last week, with the &lt;a href="https://apify.com/lergassy/pinterest-search-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;search-only Actor&lt;/a&gt;. What I did not expect is that two feeds belonging to &lt;em&gt;the same account in the same run&lt;/em&gt; disagree: the board feed hands out tokens, the &lt;code&gt;_created&lt;/code&gt; profile feed still hands out numeric ids like &lt;code&gt;1063131055810574252&lt;/code&gt;. Numeric ids answer the pin endpoint, so the "exact saves and comments" switch fired on exactly the 20 profile pins and silently did nothing for the 20 board pins.&lt;/p&gt;

&lt;p&gt;The bill shows it: &lt;code&gt;profile ×1, pin ×40, pin-details ×20 = $0.072&lt;/code&gt;. Twenty detail lookups, forty pins. If you want engagement numbers, enter the account by profile, not by board — and if an Actor charges you per detail lookup, check that it is not charging for the ones that came back empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boardUrl did not round-trip, and that one was mine
&lt;/h2&gt;

&lt;p&gt;While measuring the above I fed a &lt;code&gt;boardUrl&lt;/code&gt; from a search result straight back in as an input, which is the most obvious thing a user will ever do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.pinterest.com/cornerrenovation/kitchen-design-tips-guides-%2B-checklists/
→ board not found. Not charged.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinterest writes its own board URLs percent-encoded. Its &lt;code&gt;BoardResource&lt;/code&gt; wants the decoded slug — &lt;code&gt;kitchen-design-tips-guides-+-checklists&lt;/code&gt;. I was splitting the path and passing the segments through raw, so &lt;code&gt;%2B&lt;/code&gt; went out as three characters and Pinterest 404'd. Three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pinterest's own board URLs come percent-encoded ('…-%2B-checklists'), but its BoardResource&lt;/span&gt;
&lt;span class="c1"&gt;// wants the decoded slug — feeding a boardUrl straight back answered 'board not found'.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unesc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;try&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;decodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unesc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before: 0 pins, &lt;code&gt;board not found&lt;/code&gt;, charged $0. After, same URL, same build pipeline: &lt;strong&gt;3 pins&lt;/strong&gt;, 4 s, each with its outbound link to &lt;code&gt;cornerrenovation.com/blog/…&lt;/code&gt;. The &lt;code&gt;try/catch&lt;/code&gt; is not decoration — a malformed escape like &lt;code&gt;%E0%A4%A&lt;/code&gt; throws, and a bad input should degrade to "slug not found" rather than killing the run. Search and profile inputs re-verified on the same build before the &lt;code&gt;latest&lt;/code&gt; tag moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one hypothesis the numbers killed
&lt;/h2&gt;

&lt;p&gt;My first reading of the board feed was "it strips the text fields" — 20 board rows with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;link&lt;/code&gt; all null. That was wrong, and it was wrong in the most ordinary way: the account I happened to test publishes untitled, unlinked image pins. Its own numeric profile pins, detail lookup and all, came back equally empty. A second board from a different owner, 10 pins, same build: &lt;strong&gt;title 10 of 10, description 10 of 10, alt text 10 of 10.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the board feed is fine. One account's content was the finding. If a field is empty across your whole sample, check a second source before you write it down as an API limitation — I nearly shipped a README note saying the opposite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/lergassy~pinterest-scraper/runs?token=&lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"searchQueries":["minimalist kitchen"],
       "urls":["https://www.pinterest.com/minimalistliving01/"],
       "maxPinsPerSource":50,
       "onlyWithLink":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onlyWithLink&lt;/code&gt; is the switch I use for traffic work: on &lt;code&gt;boho bedroom&lt;/code&gt; it would have dropped 14 of 20 rows, because pins uploaded straight to Pinterest point nowhere. Link coverage swings hard by keyword — 19 of 20 on &lt;code&gt;air fryer recipes&lt;/code&gt;, 12 of 20 on &lt;code&gt;minimalist kitchen&lt;/code&gt;, 6 of 20 on &lt;code&gt;boho bedroom&lt;/code&gt; — so your traffic denominator is the linked subset, never the row count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to use this.&lt;/strong&gt; If you need saves and comments at scale, this is the wrong road: go through a business account and Pinterest's official API, which is what those numbers are for. If you want everything one creator ever pinned, enter by profile and accept that preview accounts will return nothing. And if you are trying to study the sites behind a niche rather than the pins, the links are the whole product — pull them, group them, and then go read the sites.&lt;/p&gt;

&lt;p&gt;Three keywords, 60 pins, under thirty cents. The useful output was not a ranking. It was a list of seven live content sites I would never have found by searching the web, plus the knowledge that Pinterest is handing them the distribution.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
