<?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: Nikita Iakovlev</title>
    <description>The latest articles on DEV Community by Nikita Iakovlev (@nikita_iakovlev_415524c19).</description>
    <link>https://dev.to/nikita_iakovlev_415524c19</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%2F1fd8a7b2-6bb2-458e-aa51-a97f46032554.png</url>
      <title>DEV Community: Nikita Iakovlev</title>
      <link>https://dev.to/nikita_iakovlev_415524c19</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nikita_iakovlev_415524c19"/>
    <language>en</language>
    <item>
      <title>Capterra and Software Advice Share One Review Database and Disagree by 13,058 Reviews</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Thu, 15 Oct 2026 05:12:38 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/capterra-and-software-advice-share-one-review-database-and-disagree-by-13058-reviews-jd5</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/capterra-and-software-advice-share-one-review-database-and-disagree-by-13058-reviews-jd5</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 Apify Actors, and one of them collects B2B software reviews — Capterra, Software Advice, GetApp and TrustRadius — and returns every site with the same field names.&lt;/p&gt;

&lt;p&gt;That last part started as a convenience feature — four scrapers, four field vocabularies, one translation layer I got tired of rewriting. What it turned into was a measuring instrument: once the rows line up you can put the sites side by side and see where they disagree. They disagree a lot, and not in the way I expected.&lt;/p&gt;

&lt;p&gt;Here is the measurement. Product: Slack. Builds &lt;code&gt;0.1.39&lt;/code&gt; and &lt;code&gt;0.1.40&lt;/code&gt;, runs on 13–15 October 2026, no proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sites, one database, three different totals
&lt;/h2&gt;

&lt;p&gt;Ask for Slack on Capterra, Software Advice and TrustRadius, ten reviews each, and the product header on those rows reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;source&lt;/span&gt;           &lt;span class="k"&gt;productReviewCount&lt;/span&gt;   &lt;span class="k"&gt;productRating&lt;/span&gt;   &lt;span class="k"&gt;ratingScale&lt;/span&gt;
&lt;span class="k"&gt;capterra&lt;/span&gt;                     &lt;span class="mf"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;166&lt;/span&gt;          &lt;span class="mf"&gt;4.4643&lt;/span&gt;            &lt;span class="mf"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;softwareadvice&lt;/span&gt;               &lt;span class="mf"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;224&lt;/span&gt;          &lt;span class="mf"&gt;4.6639&lt;/span&gt;            &lt;span class="mf"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;getapp&lt;/span&gt;                       &lt;span class="mf"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;224&lt;/span&gt;          &lt;span class="mf"&gt;4.6639&lt;/span&gt;            &lt;span class="mf"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;trustradius&lt;/span&gt;                  &lt;span class="mf"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;268&lt;/span&gt;               &lt;span class="mf"&gt;9&lt;/span&gt;           &lt;span class="mf"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Software Advice and GetApp agree to four decimal places. Capterra — the biggest brand of the three, the one whose logo everyone recognises — reports 11,166 reviews and an average two tenths of a star lower.&lt;/p&gt;

&lt;p&gt;My first guess was that these are simply three separate review pools that happen to overlap in subject matter. That guess is wrong, and the &lt;code&gt;reviewId&lt;/code&gt; field is what killed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;softwareadvice  Capterra___7219769
softwareadvice  Capterra___7224596
getapp          Capterra___7138404
capterra        Capterra___7209002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are reviews scraped from &lt;code&gt;softwareadvice.com&lt;/code&gt; and &lt;code&gt;getapp.com&lt;/code&gt;. They carry Capterra identifiers because all three sites are Gartner properties running on one review database. The IDs interleave, too — 13 of the 20 Software Advice IDs I collected fall &lt;em&gt;inside&lt;/em&gt; the numeric range of the 45 Capterra IDs. It is one sequence, handed out to one pool of reviews, served through three front doors with three different filters on the way out.&lt;/p&gt;

&lt;p&gt;So the 13,058-review gap is not a data gap. It is an editorial decision about which of the shared reviews each brand chooses to display, and it is invisible if you scrape one site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same review, twice, with a different reviewer on it
&lt;/h2&gt;

&lt;p&gt;Across 45 Capterra reviews and 20 Software Advice reviews I caught exactly one ID on both sites: &lt;code&gt;Capterra___7224596&lt;/code&gt;. Field by field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;field                     capterra                softwareadvice
title                     "Slack is the best…"    "Slack is the best…"   same
publishedAt               2026-10-04              2026-10-04             same
pros / cons               identical text          identical text         same
overallRating             5                       5                      same
easeOfUseRating           5                       5                      same
functionalityRating       5                       5                      same
valueForMoneyRating       5                       5                      same
customerSupportRating     4                       4                      same
incentivized              true  (NominalGift)     true  (NominalGift)    same
reviewerIndustry          Construction            Construction           same
--
reviewerName              "Chris S."              "Chris S——" (full)     differs
reviewerJobTitle          "General Manager"       null                   differs
reviewerCompanySize       "51-200 employees"      null                   differs
reviewerCompanySizeCode   null                    "D"                    differs
timeUsed                  null                    "More than 2 years"    differs
usageFrequency            null                    "Daily"                differs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything a reviewer typed is byte-identical. Everything &lt;em&gt;about&lt;/em&gt; the reviewer is split between the two pages. Capterra publishes the job title and a readable company-size range and abbreviates the surname to an initial. Software Advice publishes the reviewer's full first and last name — I'm redacting it here, since the point is the disclosure difference and not this person — drops the job title, and replaces the company-size range with a single letter.&lt;/p&gt;

&lt;p&gt;That letter is worth a paragraph of its own. Software Advice and GetApp publish &lt;code&gt;A&lt;/code&gt; through &lt;code&gt;I&lt;/code&gt; where Capterra publishes &lt;code&gt;11-50 employees&lt;/code&gt;, and neither site publishes a legend. The temptation is to map it yourself: &lt;code&gt;A&lt;/code&gt; is surely the smallest bucket, &lt;code&gt;I&lt;/code&gt; the largest, nine letters for nine ranges. I didn't, because on this one shared review the letter is &lt;code&gt;D&lt;/code&gt; and the range Capterra prints for the same company is &lt;code&gt;51-200 employees&lt;/code&gt; — a single data point, enough to build a guess on and nowhere near enough to ship one. So the Actor passes the letter through in &lt;code&gt;reviewerCompanySizeCode&lt;/code&gt; and leaves &lt;code&gt;reviewerCompanySize&lt;/code&gt; empty. One confirmed pair is a hint, not a mapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does to de-duplication
&lt;/h2&gt;

&lt;p&gt;If you are merging Capterra, Software Advice and GetApp, &lt;code&gt;reviewId&lt;/code&gt; is a real join key and you should use it. But the merge is not idempotent in either direction, because neither row is a superset of the other. Prefer the Capterra row and you lose &lt;code&gt;usageFrequency&lt;/code&gt; and &lt;code&gt;timeUsed&lt;/code&gt;. Prefer the Software Advice row and you lose the job title and the human-readable company size. The only correct merge is field-level: for each column, take whichever side is non-null.&lt;/p&gt;

&lt;p&gt;TrustRadius stays out of this entirely. Its IDs are slugs with a timestamp — &lt;code&gt;slack-2026-08-05-00-29-43&lt;/code&gt; — its reviewers are its own, and across ten rows it returned zero sub-ratings while filling in &lt;code&gt;reviewerJobTitle&lt;/code&gt; on all ten ("Full-stack developer", "Sr. Director engineering", "Senior Salesforce Administrator"). Different site, different database, genuinely additive sample.&lt;/p&gt;

&lt;p&gt;And GetApp is no longer a review source in any useful sense. Its product totals are complete and match Software Advice exactly, but the product page now publishes about six short quotes instead of full reviews, with no ratings at all. Of the five rows I got back, three had &lt;code&gt;pros&lt;/code&gt; filled and &lt;code&gt;cons&lt;/code&gt; empty, two the reverse — the page quotes one half of each review and doesn't say which half. The first one reads &lt;em&gt;"However, managing constant notifications can be overwhelming at times…"&lt;/em&gt;, which is a complaint, sitting in a field called &lt;code&gt;pros&lt;/code&gt;, because that is the only text GetApp published for it. Use GetApp for product-level numbers; don't build a sentiment set from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page one is not a sample
&lt;/h2&gt;

&lt;p&gt;One more thing I only saw because the rows lined up. Ten reviews from each site, in the order the site publishes them, no sort, no filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;capterra         5 5 3 3 4 5 5 4 4 5     avg 4.3
softwareadvice   5 5 5 5 5 5 5 5 5 5     avg 5.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same database. Software Advice's front page was a wall of five stars; Capterra's front page was not. Five of those ten Software Advice reviews were flagged &lt;code&gt;incentivized&lt;/code&gt; — &lt;code&gt;NominalGift&lt;/code&gt; or &lt;code&gt;VendorReferredIncentivized&lt;/code&gt; — against 14 of 45 on Capterra.&lt;/p&gt;

&lt;p&gt;The low ratings do exist in the shared pool, they are just not on page one. Asking for them explicitly finds them:&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;"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;"Slack"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchSites"&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;"softwareadvice"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxReviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten rows: nine 3-star and one 1-star, the oldest from 2025-10-31, six of the ten incentivized. The filter runs &lt;em&gt;while&lt;/em&gt; paging rather than after it, so the run keeps reading pages until it has what you asked for or the site runs out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;matches_filters&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="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;published_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;published_before&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# TrustRadius rates out of 10, the Gartner sites out of 5, so filtering happens on
&lt;/span&gt;    &lt;span class="c1"&gt;# the normalized 1-5 value the user is thinking in.
&lt;/span&gt;    &lt;span class="n"&gt;rating&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;normalizedRating&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;overallRating&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;That normalization is why &lt;code&gt;maxRating: 3&lt;/code&gt; means the same band of unhappy customers on TrustRadius as on Capterra. &lt;code&gt;overallRating&lt;/code&gt; keeps the site's own number, &lt;code&gt;ratingScale&lt;/code&gt; says which scale it was, &lt;code&gt;normalizedRating&lt;/code&gt; is &lt;code&gt;raw * 5 / scale&lt;/code&gt;. A TrustRadius 9 arrives as &lt;code&gt;overallRating: 9, ratingScale: 10, normalizedRating: 4.5&lt;/code&gt;, and averaging that against a Capterra 5 is finally a legal operation.&lt;/p&gt;

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

&lt;p&gt;Search by name on Software Advice and TrustRadius, by URL on Capterra and GetApp — Capterra's search page refuses automated requests even through a residential proxy, and GetApp publishes no search page at all. Their product pages are served normally, so a URL is all you need:&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~software-reviews-scraper/run-sync-get-dataset-items?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;'{
    "productUrls": ["https://www.capterra.com/p/135003/Slack/reviews/"],
    "searchTerms": ["Slack"],
    "searchSites": ["trustradius", "softwareadvice"],
    "maxReviews": 200,
    "includeProductRow": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty reviews across three sites took 46 seconds; thirty from Capterra alone took 90, because reviews come 25 to a page and each page is a request. Budget roughly three to four seconds per page. The &lt;a href="https://apify.com/lergassy/software-reviews-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Actor is here&lt;/a&gt; if you want to run the comparison on your own category — pricing is per row, $0.0012 a review and $0.003 a product summary, and failed URLs come back as &lt;code&gt;error&lt;/code&gt; rows at no charge.&lt;/p&gt;

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

&lt;p&gt;Don't use it to count. If you need "how many reviews does this product have", you now know that question has two different right answers depending on which Gartner brand you ask, and any single number you quote is a brand's choice rather than a fact.&lt;/p&gt;

&lt;p&gt;Don't use it for G2 — different site, different gate, not covered here. And if what you want is volume over time, note that &lt;code&gt;publishedAt&lt;/code&gt; is reliable but the sites' own ordering is not chronological, so a date histogram needs the full history pulled, not a recent slice.&lt;/p&gt;

&lt;p&gt;Do use it when the comparison is the point: the same product across four sites, the same columns, and a normalized rating that makes an average mean something. Just merge field-level, and treat page one as a storefront rather than a sample.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>data</category>
    </item>
    <item>
      <title>Ten YouTube Comments, One Commenter: What a Reply Budget Does to Your Sample</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 21:11:36 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/ten-youtube-comments-one-commenter-what-a-reply-budget-does-to-your-sample-31ii</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/ten-youtube-comments-one-commenter-what-a-reply-budget-does-to-your-sample-31ii</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The part of my week that has nothing to do with visas goes into Apify Actors, and one of them pulls YouTube comments — one video, a channel, a playlist, or the top results of a search, one row per comment with likes, reply count, the pinned and hearted flags and the commenter's channel.&lt;/p&gt;

&lt;p&gt;People use comment data for sentiment: pull the last twenty videos of a competitor, feed the text to a model, read what their audience actually complains about. That works, and the scraping part is not the hard bit. The hard bit is that two of the three obvious assumptions about a comment sample are wrong, and both of them are wrong &lt;em&gt;quietly&lt;/em&gt; — you get a full dataset, the right number of rows, no errors, and a conclusion that isn't about the audience.&lt;/p&gt;

&lt;p&gt;Here is the measurement, from runs on 11–14 October 2026, build &lt;code&gt;0.1.39&lt;/code&gt;, no proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  One flag, two completely different samples
&lt;/h2&gt;

&lt;p&gt;Same video — &lt;code&gt;jNQXAC9IVRw&lt;/code&gt;, "Me at the zoo", the first video on YouTube, 20 years of comments on it. Same limit: 10 comments. Sorted by top. The only difference between the two runs is &lt;code&gt;includeReplies&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With replies &lt;strong&gt;off&lt;/strong&gt;, ten comments from ten different people:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"We're so honored that the first ever YouTube video was filmed here!"   4,800,000 likes
"So we're all here again on October 14th 2026 right"                          746
"Hello"                                                                   303,000
"Is anyone else watching this on a random day in October?"                  1,000
"I'm glad this pops up on my feed every couple years"                         467
"This started it all...... bruh 😳"                                       248,000
"this video lived long enough to see gifs come true"                          459
'Seeing "20 years ago" on a YouTube video makes me feel a certain way'    211,000
'This guys is the only person who has the rights to say "first"'          418,000
"First word on Youtube ever:\nAlright"                                    115,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With replies &lt;strong&gt;on&lt;/strong&gt; — the default — and the same &lt;code&gt;maxCommentsPerVideo: 10&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;isReply&lt;/span&gt;  &lt;span class="k"&gt;text&lt;/span&gt;                                                          &lt;span class="k"&gt;likes&lt;/span&gt;
&lt;span class="k"&gt;false&lt;/span&gt;    &lt;span class="s2"&gt;"We're so honored that the first ever YouTube video was…"&lt;/span&gt;  &lt;span class="mf"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"San Diego Zoo That's so awesome! I'm so glad the first…"&lt;/span&gt;    &lt;span class="mf"&gt;206&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"Very cool!"&lt;/span&gt;                                                  &lt;span class="mf"&gt;71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"cool"&lt;/span&gt;                                                        &lt;span class="mf"&gt;57&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"."&lt;/span&gt;                                                           &lt;span class="mf"&gt;54&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"How did I randomly go to this video and see this comment…"&lt;/span&gt;   &lt;span class="mf"&gt;57&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"Im here before this blows up"&lt;/span&gt;                                &lt;span class="mf"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;" @Quetzal_  lol what"&lt;/span&gt;                                        &lt;span class="mf"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"yoo here before thousands of likes"&lt;/span&gt;                          &lt;span class="mf"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;span class="k"&gt;true&lt;/span&gt;     &lt;span class="s2"&gt;"Awesome!"&lt;/span&gt;                                                    &lt;span class="mf"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One top-level comment and nine replies to it. Ten rows, one conversation, and four of them are a single word or a full stop. Run that through a sentiment model and you have measured one thread on a video with 2.4 million comments.&lt;/p&gt;

&lt;p&gt;This is not a bug, and the input field says so in six words — &lt;em&gt;"replies count too when included"&lt;/em&gt;. It is the interaction that bites. The collector walks the comment list depth-first: take a top-level comment, immediately follow its replies, then move to the next one. Both compete for the same budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pushComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;includeReplies&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replyCount&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replyTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …up to maxRepliesPerComment replies, each one incrementing the same `kept`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment at the top of "Me at the zoo" has 985 replies and &lt;code&gt;maxRepliesPerComment&lt;/code&gt; defaults to 20. So the first comment plus its reply cap is 21 rows — more than the sample I asked for. The depth-first order is the right default (a thread stays together, and a keyword match inside a thread is still a match), but it means the limit you set is a &lt;em&gt;row&lt;/em&gt; budget, not a &lt;em&gt;voice&lt;/em&gt; budget.&lt;/p&gt;

&lt;p&gt;The arithmetic to set it deliberately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows ≈ topLevelComments × (1 + maxRepliesPerComment)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty distinct opinions with their threads at the default reply cap is 420 rows, not 20. If you want breadth, &lt;code&gt;includeReplies: false&lt;/code&gt; is the whole fix; the reply count stays on every row, so you still know which comments started something. If you want threads, raise the comment limit by a factor of 21 and expect to pay for it — the Actor bills per comment row, so replies are rows you buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I ran the identical input four days running
&lt;/h2&gt;

&lt;p&gt;Four runs, 11 to 14 October, same JSON every time: that video, &lt;code&gt;maxCommentsPerVideo: 50&lt;/code&gt;, replies on, &lt;code&gt;sort: top&lt;/code&gt;. Four datasets of exactly 50 rows.&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;Rows&lt;/th&gt;
&lt;th&gt;Distinct comments shared with the 14 Oct run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11 Oct&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 Oct&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 Oct&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the four runs there are &lt;strong&gt;121 distinct comments&lt;/strong&gt;, and only &lt;strong&gt;21 appear in all four&lt;/strong&gt;. Every run returned the same three top-level comments' worth of structure — 3 top-level, 47 replies, the first thread always the 4.8-million-like one — but comments two and three changed almost every day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;11 Oct: "Randomly got recommended this at 11:16 PM…"          3,300 likes
12 Oct: "You will never know why this comment got…"          24,000
13 Oct: 'Seeing "20 years ago" on a YouTube video…'         211,000
14 Oct: "This started it all...... bruh 😳"                  247,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Top comments" is a ranking YouTube recomputes, not a stable list, and on a video this size the tail of the top-50 reshuffles daily. The same comment also came back with 417,000 likes on one day and 418,000 on the next, which is the honest behaviour — counts move — but it means a comment is identified by &lt;code&gt;commentId&lt;/code&gt;, never by its text plus its like count. If you are tracking a video over time, dedupe on &lt;code&gt;commentId&lt;/code&gt; and expect the set to drift under you. If you are sampling once for sentiment, know that you sampled &lt;em&gt;a&lt;/em&gt; top-50, not &lt;em&gt;the&lt;/em&gt; top-50.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the dates are arithmetic, not data
&lt;/h2&gt;

&lt;p&gt;YouTube never shows a comment date. It shows an age: "6 years ago", "9 hours ago", "2 days ago". The Actor keeps that string in &lt;code&gt;publishedAgo&lt;/code&gt; and also subtracts it from the run clock into &lt;code&gt;publishedAt&lt;/code&gt;, because a date column is what every spreadsheet downstream wants. Here is what that does to one comment — the same &lt;code&gt;commentId&lt;/code&gt;, five runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run started (UTC)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;publishedAgo&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;publishedAt&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11 Oct 00:33&lt;/td&gt;
&lt;td&gt;6 years ago&lt;/td&gt;
&lt;td&gt;2020-10-10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 Oct 21:28&lt;/td&gt;
&lt;td&gt;6 years ago&lt;/td&gt;
&lt;td&gt;2020-10-12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 Oct 00:28&lt;/td&gt;
&lt;td&gt;6 years ago&lt;/td&gt;
&lt;td&gt;2020-10-12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 00:45&lt;/td&gt;
&lt;td&gt;6 years ago&lt;/td&gt;
&lt;td&gt;2020-10-13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 21:05&lt;/td&gt;
&lt;td&gt;6 years ago&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2020-10-14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two runs on the same calendar day, two different dates, because the subtraction uses the run's clock and a year is 365.25 days. The mapping is not even one-to-one in either direction: the 12 Oct and 13 Oct runs, three hours apart, both landed on 2020-10-12, while the two 14 Oct runs landed a day apart.&lt;/p&gt;

&lt;p&gt;The drift is the small problem. The real one is the bucket: "6 years ago" is YouTube rounding down, so the comment's true age is somewhere in &lt;code&gt;[6 years, 7 years)&lt;/code&gt;. &lt;code&gt;publishedAt: 2020-10-13&lt;/code&gt; is the &lt;strong&gt;newest date the comment could have&lt;/strong&gt;; it could genuinely be from October 2019. For fresh comments the label is tight — "9 hours ago" pins the day — and for anything older than a month it is a floor, not a date. So: filter and group on &lt;code&gt;publishedAgo&lt;/code&gt; buckets when the comments are old, use &lt;code&gt;publishedAt&lt;/code&gt; for recency windows on fresh comments, and never diff two runs' &lt;code&gt;publishedAt&lt;/code&gt; columns and conclude anything.&lt;/p&gt;

&lt;p&gt;One more small trap in the same field: an edited comment is labelled &lt;code&gt;"6 years ago (edited)"&lt;/code&gt;, suffix and all. Across those four runs there were 22 such rows and four distinct label strings — &lt;code&gt;6 years ago (edited)&lt;/code&gt;, &lt;code&gt;5 years ago (edited)&lt;/code&gt;, &lt;code&gt;5 months ago (edited)&lt;/code&gt;, &lt;code&gt;1 month ago (edited)&lt;/code&gt;. Matching &lt;code&gt;publishedAgo == "6 years ago"&lt;/code&gt; silently drops them. The date parser strips the suffix before doing its arithmetic; a &lt;code&gt;GROUP BY&lt;/code&gt; in your sheet does not.&lt;/p&gt;

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

&lt;p&gt;One request, and the run-sync endpoint hands back the rows directly — this is the exact call that produced the ten-distinct-commenters sample above:&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~youtube-comments-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;videoUrls&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://www.youtube.com/watch?v=jNQXAC9IVRw&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;maxCommentsPerVideo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;includeReplies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# breadth, not one thread
&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;top&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;language&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;en&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;videoUrls&lt;/code&gt; for &lt;code&gt;channelUrls&lt;/code&gt;, &lt;code&gt;playlistUrls&lt;/code&gt; or &lt;code&gt;searchQueries&lt;/code&gt; and &lt;code&gt;maxVideosPerSource&lt;/code&gt; decides how many videos each source contributes; every row carries &lt;code&gt;source&lt;/code&gt; so you know where it came from. The Actor is here: &lt;a href="https://apify.com/lergassy/youtube-comments-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;YouTube Comments Scraper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to reach for it.&lt;/strong&gt; If you need exact comment timestamps, nothing scraped from the watch page will give them to you — the official Data API returns real &lt;code&gt;publishedAt&lt;/code&gt; values, at the cost of a key and a daily quota. If you need &lt;em&gt;all&lt;/em&gt; comments of a viral video, the arithmetic is unkind: twenty comments per request means 2.4 million comments is 120,000 round trips. And if you want to post, reply or moderate, this is read-only; that is the API's job.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning on other people's data: a limit is only meaningful once you know what the thing being limited is. Ten rows looked like ten opinions right up until I printed the &lt;code&gt;isReply&lt;/code&gt; column.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>youtube</category>
    </item>
    <item>
      <title>One Minute of Audio, Three Runs: 13 Segments, Then 9, One of Them 21.94 Seconds Long</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:11:52 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/one-minute-of-audio-three-runs-13-segments-then-9-one-of-them-2194-seconds-long-1633</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/one-minute-of-audio-three-runs-13-segments-then-9-one-of-them-2194-seconds-long-1633</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 Apify Actors, and one of them is a transcriber: give it an audio or video URL, it runs Whisper large v3 and hands back a transcript, timed segments, SRT or WebVTT subtitles, and optional retrieval chunks that keep their timecodes.&lt;/p&gt;

&lt;p&gt;The transcript is the part everyone checks. It is also the part that is nearly stable. What is &lt;em&gt;not&lt;/em&gt; stable is the structure around it, and almost every pipeline I have seen built on a transcription API quietly assumes the opposite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three runs of the same file
&lt;/h2&gt;

&lt;p&gt;One sample: a 60.01-second m4a of someone reading the opening of &lt;em&gt;Pride and Prejudice&lt;/em&gt; three times over. Same Actor build (&lt;code&gt;IdudfuIRo8hXFkRgH&lt;/code&gt;), same input — &lt;code&gt;quality: fast&lt;/code&gt; (Whisper large v3 turbo), &lt;code&gt;language: en&lt;/code&gt;. Run on 13 October, 14 October, and again on 14 October twelve hours later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Segments&lt;/th&gt;
&lt;th&gt;Words&lt;/th&gt;
&lt;th&gt;Chars&lt;/th&gt;
&lt;th&gt;Longest segment&lt;/th&gt;
&lt;th&gt;Longest segment text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;13 Oct 00:24&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;td&gt;1162&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.94 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;414 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 00:39&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;215&lt;/td&gt;
&lt;td&gt;1163&lt;/td&gt;
&lt;td&gt;5.32 s&lt;/td&gt;
&lt;td&gt;100 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 13:09&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;td&gt;1162&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.94 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;414 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The words are fine. A &lt;code&gt;diff&lt;/code&gt; of the two transcripts that differ at all is two tokens out of 215:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- neighborhood. This truth is so well fixed
&lt;/span&gt;&lt;span class="gi"&gt;+ neighborhood, this truth is so well fixed
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;- property of some one or other
&lt;/span&gt;&lt;span class="gi"&gt;+ property of someone or other
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A comma that became a full stop, and a space that appeared inside "someone". If you are measuring word error rate, this file is stable to 0.5%.&lt;/p&gt;

&lt;p&gt;Now look at the structure. Between those same two runs the segment count moved 44%, and the longest unit of output went from five seconds to twenty-two.&lt;/p&gt;

&lt;p&gt;And here is my favourite detail, which has nothing to do with run-to-run variance: the recording loops the same sentence three times, so one run contains the same audio three times over. The 14 October run transcribed it as &lt;strong&gt;"someone or other"&lt;/strong&gt; at 15.30 s and as &lt;strong&gt;"some one or other"&lt;/strong&gt; at 34.50 s and 53.74 s. Identical audio, one run, two spellings. Do not build a deduplicator that compares transcript strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a 21.94-second segment exists
&lt;/h2&gt;

&lt;p&gt;Whisper works in windows of up to 30 seconds and returns segments, not words. How it carves a window into segments depends on where it thinks the sentences end — and that decision is downstream of sampling, so it moves between runs even when the text does not. Nine segments and thirteen segments are both legitimate readings of the same sixty seconds.&lt;/p&gt;

&lt;p&gt;The 9-segment run put the final third of the file into one object:&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;"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;38.06&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;21.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;"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, must be in want of a wife. However little known…"&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;414 characters with a single start time. Pass segments straight into an SRT writer — the obvious implementation, and the one I shipped first — and a viewer gets a paragraph filling the screen for twenty-two seconds. I caught it on a &lt;code&gt;jfk.flac&lt;/code&gt; test last September: 108 characters held for 11 seconds, valid SRT, unwatchable.&lt;/p&gt;

&lt;p&gt;So subtitles are not built from segments here. They are rebuilt from them:&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;LINE_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;       &lt;span class="c1"&gt;# the Netflix/BBC reading norm
&lt;/span&gt;&lt;span class="n"&gt;CUE_MAX&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;LINE_MAX&lt;/span&gt;
&lt;span class="n"&gt;CUE_SECONDS_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;

&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;CUE_MAX&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;CUE_SECONDS_MAX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each segment is split into at least that many cues, preferring breaks after a comma or a sentence end, then timed by dividing the segment's duration in proportion to characters. That proportional split is an approximation and I want to be honest about it: the API gives segment timings only, no word timestamps, so there is nothing better available without a forced-alignment pass.&lt;/p&gt;

&lt;p&gt;Those 9 segments came out as 21 cues, longest 4.51 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16
00:00:38,060 --&amp;gt; 00:00:42,405
It is a truth universally acknowledged,
that a single man in possession of a good

17
00:00:42,405 --&amp;gt; 00:00:46,750
fortune, must be in want of a wife.
However little known the feelings or views of
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One measured line came out 45 characters rather than 42: the wrapper needs a break point that leaves both halves under the limit, and a long unbroken clause has none, so it falls back to the nearest space. Three characters over beats hyphenating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The segments also do not cover the file
&lt;/h2&gt;

&lt;p&gt;Add up the durations: 57.12 s, 58.12 s, 58.30 s against a 60.01-second file. Between two and three seconds of every run live in the gaps between segments. On a reading with real pauses that is correct behaviour.&lt;/p&gt;

&lt;p&gt;Sometimes it is not. Whisper can skip to its next 30-second window and silently drop the speech in between, and the status still says &lt;code&gt;ok&lt;/code&gt; — a 134-second reading came back with roughly 32 seconds missing in one of my golden checks. The transcript reads fluently. Nothing in the output says a third of it is gone.&lt;/p&gt;

&lt;p&gt;The Actor now scans for holes of 6 seconds or more, cuts each one out of the prepared audio, and recognises it again with the preceding text as a prompt. Recovered segments are only kept if the service itself is confident it heard speech:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;no_speech_prob&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;avg_logprob&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That filter is not optional. Whisper hallucinates over silence, and its favourite invention is a polite &lt;code&gt;"Thank you."&lt;/code&gt; Re-check a genuine pause without the confidence gate and you fill real silences with phantom gratitude, which is worse than the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this breaks downstream
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Retrieval chunks inherit the instability.&lt;/strong&gt; Chunks are assembled from segments, breaking on sentence ends. The 9-segment run gave me three chunks starting at 0, 25.54 and 38.06 seconds, with 491, 255 and 414 characters. The 13-segment run of the same audio cannot give the same boundaries — it has different sentence ends to break on. So: never use chunk index or chunk start time as a stable key in a vector store. Re-embed whole files, keyed on the file, and delete the old vectors rather than diffing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never diff two transcription runs.&lt;/strong&gt; Any sane diff of these three runs reports changes. One is a real text change of two tokens; the rest is re-segmentation noise. If you need change detection on a recurring recording, compare normalised text — lowercased, punctuation stripped, whitespace collapsed — and never segment counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speaker labels are per-run identities.&lt;/strong&gt; They are computed inside the Actor from the audio (Silero VAD to cut phrases at pauses, 3D-Speaker CAM++ for one voice print per phrase, then clustering). Clustering happens per file, so &lt;code&gt;Speaker 1&lt;/code&gt; in Monday's run and &lt;code&gt;Speaker 1&lt;/code&gt; in Tuesday's run are not promised to be the same human. For cross-file identity you need enrolled voice prints, which is a different product.&lt;/p&gt;

&lt;p&gt;One counter-example, because I only have two data points on it: the other URL in those runs was a 19.02-second YouTube clip, and it came back byte-identical on both days, with segment boundaries on whole seconds (0, 4, 12, 16). Short clean clips may well be stable. I would not build on the assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling it
&lt;/h2&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~speech-to-text/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;urls&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://example.com/episode-12.mp3&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;language&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;en&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;subtitleFormat&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;srt&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;chunkForRag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunkSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actor is &lt;a href="https://apify.com/lergassy/speech-to-text?fpr=wmeplu" rel="noopener noreferrer"&gt;Speech to Text&lt;/a&gt;. One row per file, no key to set up; the third run in this post cost $0.011 — one billed minute at $0.009 plus $0.002 for the file. Minutes are charged per started minute, so that 19-second YouTube clip billed a full minute, and failed files cost nothing. Direct file links pay no proxy fee; a YouTube, TikTok or Instagram &lt;em&gt;page&lt;/em&gt; does, because its audio has to come through a residential exit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it is the wrong tool.&lt;/strong&gt; Live or streaming audio — this is batch, one row per finished file. Word-level timestamps or karaoke-style alignment: segment timings are all the API exposes, and my cue splitter interpolates rather than aligns. Cross-recording speaker identity, per the point above. And if you already run Whisper on your own GPU, nothing here beats that on price; what you are buying is the download, the ffmpeg prep, the splitting of long files, the gap re-check and the retries.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: when a model returns structure as well as content, the content is the part people test and the structure is the part they build on. Check which of the two your pipeline actually depends on — and if the answer is structure, measure it twice on the same input before you ship.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>audio</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Asking KAYAK for 10 Flights Instead of 50 Made the Same Seat 30% Dearer</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:13:08 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/asking-kayak-for-10-flights-instead-of-50-made-the-same-seat-30-dearer-bp3</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/asking-kayak-for-10-flights-instead-of-50-made-the-same-seat-30-dearer-bp3</guid>
      <description>&lt;p&gt;I run a visa agency in Bali; the other half of my week goes into Apify Actors. One of them reads KAYAK flight searches into rows: price, &lt;strong&gt;who is selling the ticket&lt;/strong&gt; — airline or agency — flight numbers, times, stops, layovers, aircraft, and a booking link.&lt;/p&gt;

&lt;p&gt;A metasearch engine is worth scraping precisely because of that seller column. Google Flights tells you a route costs $57; KAYAK tells you which of eleven sellers is quoting $57 and links to their checkout. So the seller field had better be right.&lt;/p&gt;

&lt;p&gt;Last week I noticed it was not — sometimes. Four runs of the same search disagreed about the price, and not randomly: they disagreed &lt;strong&gt;in one direction&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four runs, one search
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DPS&lt;/code&gt; → &lt;code&gt;SIN&lt;/code&gt;, departing 15 March 2027, one way, economy, sorted by KAYAK's "best". Run on 13 and 14 October, two of them three minutes apart. Here is the same Qantas-coded itinerary in each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Row cap&lt;/th&gt;
&lt;th&gt;Took&lt;/th&gt;
&lt;th&gt;&lt;code&gt;price&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;provider&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;providersForThisFlight&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;13 Oct 00:18&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;23 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;115&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kiwi.com&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 Oct 00:21&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;34 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Justfly.com&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 00:34&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;74 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;115&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kiwi.com&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 Oct 00:36&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;23 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Justfly.com&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the 24 itineraries the 13 October pair had in common, the second run was cheaper on 18, identical on 6, and &lt;strong&gt;dearer on none&lt;/strong&gt;. Median gap 7.1%, biggest 43.8%.&lt;/p&gt;

&lt;p&gt;A price that only ever moves one way is not noise. It is a partially-read field.&lt;/p&gt;

&lt;h2&gt;
  
  
  KAYAK's search is a poll, and the payload grows
&lt;/h2&gt;

&lt;p&gt;KAYAK's results page does not fetch a list of flights. It opens a search and then polls it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /i/api/search/dynamic/flights/poll
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each answer carries &lt;code&gt;results[]&lt;/code&gt;, and each result carries &lt;code&gt;bookingOptions[]&lt;/code&gt; — one entry per seller quoting that itinerary, with its own price and deep link. The Actor reads those answers directly instead of scraping the rendered DOM, and picks the cheapest option per itinerary:&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bookingOptions&lt;/span&gt; &lt;span class="o"&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="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;displayPrice&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allProviders&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correct code over an incomplete input. Sellers do not all answer KAYAK at once, so an early poll answer carries one or two &lt;code&gt;bookingOptions&lt;/code&gt; per itinerary and a later one carries thirty. &lt;code&gt;price&lt;/code&gt; is a minimum over whatever has arrived — and the minimum of a growing set, taken early, is too high every time, by construction.&lt;/p&gt;

&lt;p&gt;The run log of the two 13 October runs says it plainly — same search, 180 seconds apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:19:17  DPS-SIN: search API 2 calls, 36 flights in 5 s (page 1 s, search API +2 s)
00:19:17  DPS-SIN 2027-03-15: 29 rows (KAYAK found 29 itineraries)

00:21:55  DPS-SIN: search API 2 calls, 63 flights in 15 s (page 2 s, search API +8 s)
00:21:55  DPS-SIN 2027-03-15: 50 rows (KAYAK found 488 itineraries)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"KAYAK found 29 itineraries" against "KAYAK found 488" — the search's own &lt;code&gt;totalCount&lt;/code&gt; is a snapshot too, read five seconds in instead of fifteen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stop condition was mine, not KAYAK's
&lt;/h2&gt;

&lt;p&gt;Why did one run stop at five seconds? Because of this line in my wait loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// n = flights seen so far&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfectly reasonable as a row-count guard: the buyer asked for 30 rows, 36 itineraries have arrived, stop waiting. Except the poll answer that first clears your row cap is also the &lt;strong&gt;thinnest&lt;/strong&gt; one on prices. The row cap had quietly become the price-completeness setting, and nothing in the input hinted at that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlled test, same minute
&lt;/h2&gt;

&lt;p&gt;Two runs started in the same second from one account, identical input except the cap, both on 4 GB:&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;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DPS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"departureDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2027-03-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="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;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DPS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"departureDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2027-03-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten itineraries appear in both. Six are cheaper in the 50-row run, four are identical, &lt;strong&gt;none is dearer&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;Flight&lt;/th&gt;
&lt;th&gt;cap 10&lt;/th&gt;
&lt;th&gt;sellers&lt;/th&gt;
&lt;th&gt;cap 50&lt;/th&gt;
&lt;th&gt;sellers&lt;/th&gt;
&lt;th&gt;gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JQ86&lt;/td&gt;
&lt;td&gt;$74 Kiwi.com&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$57 Jetstar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;29.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QF5659&lt;/td&gt;
&lt;td&gt;$115 Kiwi.com&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$91 Justfly.com&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;26.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA842&lt;/td&gt;
&lt;td&gt;$129 Kiwi.com&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$106 Mytrip&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;21.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8B553&lt;/td&gt;
&lt;td&gt;$99 Kiwi.com&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$89 Super.com&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;11.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQ937&lt;/td&gt;
&lt;td&gt;$151 Singapore Airlines&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$141 Super.com&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;7.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KL836&lt;/td&gt;
&lt;td&gt;$83 KLM&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;$83 KLM&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median seller count 4 against 8. The $57 Jetstar fare — the actual cheapest way to fly Bali to Singapore that day — is simply absent from the ten-row run. Jetstar had not answered yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bias has a name, and it is Kiwi.com
&lt;/h2&gt;

&lt;p&gt;The seller column is where this gets worse than a wrong price, because the error is not evenly spread. Across all six runs, I split every row by how many sellers had reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows with 1–2 sellers reported (55):  Kiwi.com 45 · CheapOair 3 · VietJet 3 · Booking.com 3 · PAL 1
rows with 10+ sellers reported (80):  Super.com 28 · Booking.com 24 · Mytrip 18 · Justfly 3 · airlines 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kiwi.com is named the cheapest seller in &lt;strong&gt;45 of 55&lt;/strong&gt; thin rows and in &lt;strong&gt;none&lt;/strong&gt; of the 80 full ones. Kiwi is not cheap here; Kiwi is &lt;em&gt;fast&lt;/em&gt;. Read a search early and you are not measuring who sells the route cheapest, you are measuring whose API answers KAYAK first — and then publishing it as a market finding. That is the kind of error that survives review, because every individual row looks plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;providersForThisFlight&lt;/code&gt; is the confidence field.&lt;/strong&gt; It ships on every row and I had been treating it as trivia. One or two sellers on a popular route means you caught the search mid-fill; ten-plus means the field has settled. For price monitoring, store it next to the price and drop the thin rows rather than averaging them in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never compare runs taken with different caps.&lt;/strong&gt; Two of my four runs were a fair comparison; the other pair was comparing a five-second read with a fifteen-second one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix I'm making&lt;/strong&gt; is to stop treating row count as the exit condition. The poll payload already says &lt;code&gt;status: "complete"&lt;/code&gt;, and seller counts stop growing before the itinerary list does — waiting on those instead of on &lt;code&gt;maxResults&lt;/code&gt; costs about ten seconds per search and removes the whole class of error. Until that build is out, pass a high &lt;code&gt;maxResults&lt;/code&gt; even if you only intend to keep the first few rows; rows beyond the cap are not charged, and the extra wait is what buys you the right price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three other things this Actor had to learn about KAYAK
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The layover belongs to the arriving segment.&lt;/strong&gt; KAYAK attaches the connection wait to the segment that &lt;em&gt;ends&lt;/em&gt; at the connecting airport — &lt;code&gt;EI106&lt;/code&gt; JFK→DUB carries the 75 minutes spent in Dublin — so the obvious read gives you every layover airport one hop early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;currency=&lt;/code&gt; is a suggestion.&lt;/strong&gt; Ask kayak.com for a route in SAR and the prices come back in dollars, correctly labelled as dollars by the payload and not by your request. Rows now convert at a daily rate and keep &lt;code&gt;priceOriginal&lt;/code&gt;, &lt;code&gt;currencyOriginal&lt;/code&gt; and &lt;code&gt;exchangeRate&lt;/code&gt; so nothing is silently rebadged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether you get a payload at all is about the IP, not the route.&lt;/strong&gt; From today's log, the 50-row run on its first address: &lt;code&gt;requests 3 (api 0, failed 52)&lt;/code&gt; — page loaded, search never started. A fresh address returned 64 flights in 22 seconds.&lt;/p&gt;

&lt;p&gt;Here is the Actor: &lt;a href="https://apify.com/lergassy/kayak-flights-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Kayak Flights Scraper&lt;/a&gt;. Calling it is one request; &lt;code&gt;allProviders&lt;/code&gt; gives you every seller of an itinerary as its own row, which is the honest shape for seller research:&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~kayak-flights-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;origin&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;DPS&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;destination&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;SIN&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;departureDate&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;2027-03-15&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;maxResults&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allProviders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When not to reach for it.&lt;/strong&gt; A price calendar or an "is today cheap" verdict is Google's data, not KAYAK's. Seat maps, fare rules and baggage allowances are not on the results page at all. And for one route tracked hourly, a metasearch read costs 30–90 seconds of browser time per search — an airline's own API, where you can get one, is steadier.&lt;/p&gt;

&lt;p&gt;The lesson I keep paying for: when a site streams its answer, every field you read has a timestamp, whether or not it carries one. Stopping early is not a performance choice. It is a data choice.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>api</category>
      <category>travel</category>
    </item>
    <item>
      <title>Three Supermarkets Spell One Brand Three Ways, So My Price Comparison Never Happened</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:21:48 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/three-supermarkets-spell-one-brand-three-ways-so-my-price-comparison-never-happened-49d3</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/three-supermarkets-spell-one-brand-three-ways-so-my-price-comparison-never-happened-49d3</guid>
      <description>&lt;p&gt;Visas pay my bills — I run an agency in Bali. The other half of my week goes into Apify Actors, and one of them reads the three Australian supermarkets in one run: ask for &lt;code&gt;full cream milk&lt;/code&gt; and you get what Coles sells, what Woolworths sells and what ALDI sells, in the same columns.&lt;/p&gt;

&lt;p&gt;I assumed the hard part would be getting in. It partly is — Coles sits behind a bot check — but Woolworths answers its own search service in a fraction of a second and ALDI runs a plain product API. A 46-product run finishes in six seconds. The hard part turned out to be the question that sounds trivial: &lt;strong&gt;are these two rows the same product?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The run that showed the problem
&lt;/h2&gt;

&lt;p&gt;One keyword, three chains, 20 products each: Coles returned 20, Woolworths 20, ALDI 8. The comparison output is supposed to collapse those into one row per product with three prices side by side.&lt;/p&gt;

&lt;p&gt;What came out: &lt;strong&gt;38 comparison rows, of which only 7 had more than one chain on them.&lt;/strong&gt; Everything else was a "single" — one chain, no comparison. For a tool whose entire reason to exist is putting three prices on one row, that is close to a silent failure: the rows look fine, the schema is right, and the field you came for is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three causes, all in the data rather than the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One brand, three spellings
&lt;/h2&gt;

&lt;p&gt;Here is the brand field for the same 2L bottle of a2 full cream milk, as the three chains publish it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chain&lt;/th&gt;
&lt;th&gt;&lt;code&gt;brand&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;name&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;price&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Coles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a2 Milk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a2 Milk Full Cream Milk&lt;/td&gt;
&lt;td&gt;7.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Woolworths&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a2 Milk Full Cream 2L&lt;/td&gt;
&lt;td&gt;7.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALDI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A2 THE A2 MILK COMPANY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A2 THE A2 MILK COMPANY A2 Full Cream Milk 2L&lt;/td&gt;
&lt;td&gt;7.29&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My matcher required the brands to agree before it would put two rows together, and "agree" meant this:&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;brandKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt; &lt;span class="o"&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;toLowerCase&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;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&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="c1"&gt;// 'a2milk' · 'a2' · 'a2thea2milkcompany'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different strings, so three groups of one. The one deliberate exception — house brands, where Coles Full Cream Milk, Woolworths Full Cream Milk and ALDI's Farmdale are treated as comparable — was doing all the visible work. Everything branded fell apart.&lt;/p&gt;

&lt;p&gt;The fix is to compare brands as sets of words instead of strings, with the corporate boilerplate dropped:&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;BRAND_NOISE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;co&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ltd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;group&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;holdings&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;brandTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&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;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&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;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&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="nf"&gt;split&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&lt;/span&gt;&lt;span class="sr"&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="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;BRAND_NOISE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)))];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;brandsAgree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;ta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;brandTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;tb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;brandTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;shorter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;longer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ta&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;shorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;longer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// {a2} ⊆ {a2, milk}&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;{a2}&lt;/code&gt; is a subset of &lt;code&gt;{a2, milk}&lt;/code&gt;, and &lt;code&gt;A2 THE A2 MILK COMPANY&lt;/code&gt; reduces to &lt;code&gt;{a2, milk}&lt;/code&gt; once &lt;code&gt;the&lt;/code&gt; and &lt;code&gt;company&lt;/code&gt; go. One row, three prices: &lt;strong&gt;$7.30, $7.30, $7.29.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same fix caught something I was not looking for: Coles files the 750 mL Farmhouse Gold bottle under brand &lt;code&gt;Farmhouse&lt;/code&gt;, Woolworths under &lt;code&gt;Farmhouse Gold&lt;/code&gt;. Subset, so they now meet — &lt;strong&gt;$3.90 at Coles against $3.40 at Woolworths&lt;/strong&gt;, a 13% gap, because Woolworths had it marked down that day. Invisible in every earlier run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where I stopped.&lt;/strong&gt; Same brand, 1.5 L size: Coles calls it &lt;code&gt;Pauls&lt;/code&gt;, Woolworths calls it &lt;code&gt;Farmhouse Gold&lt;/code&gt;. No shared word, so those stay two single rows unless you ask for the looser &lt;code&gt;equivalent&lt;/code&gt; mode. I could close it by reading the brand out of the name — Coles' is "Pauls Farmhouse Gold Full Cream Milk", so the words are right there. I decided not to: Woolworths also sells "Farmhouse Gold Unhomogenised Milk 1.5L", and a matcher that infers brand from names will pair that with the wrong jug. A missing comparison is a gap a buyer can see; a wrong comparison is a number they will believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Word overlap pairs two different jugs
&lt;/h2&gt;

&lt;p&gt;Which brings me to the opposite failure, one row down in the same output: &lt;code&gt;colesName&lt;/code&gt; "Pauls Zymil Lactose Free &lt;strong&gt;Low Fat&lt;/strong&gt; Milk", &lt;code&gt;woolworthsName&lt;/code&gt; "Pauls Zymil Lactose Free &lt;strong&gt;Full Cream&lt;/strong&gt; Milk 2L", &lt;code&gt;matchConfidence&lt;/code&gt; 0.81, &lt;code&gt;priceGapPercent&lt;/code&gt; 14.&lt;/p&gt;

&lt;p&gt;Same brand, same normalised 2000 ml, four words in common out of eight, so the overlap score cleared the threshold and the Actor announced a 14% saving between two products that are not the same product. There was a second one: Coles' plain long-life milk paired with ALDI's &lt;strong&gt;lactose-free&lt;/strong&gt; long-life, reported as 12%.&lt;/p&gt;

&lt;p&gt;Groceries declare their differences in the title, and the declarations come in two shapes that need opposite treatment:&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;// Exclusive: conflict only when BOTH names declare a value and the values differ.&lt;/span&gt;
&lt;span class="c1"&gt;// A chain that does not write "full cream" in the title is normal.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ATTR_EXCLUSIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;fat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;full cream|whole milk&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;light|lite&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="na"&gt;lowfat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;low fat|hi lo&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;skim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;skim|no fat|fat free&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;soy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;soy&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;oat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;oat&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;almond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;almond&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;flavour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;chocolate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;chocolate|choc&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;strawberry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;strawberry&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Flags: conflict when one name carries it and the other does not. Plain milk is&lt;/span&gt;
&lt;span class="c1"&gt;// not lactose-free milk, and the silent name is the plain product.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ATTR_FLAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;lactoseFree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;lactose free|free from lactose|zymil&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;organic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;organic&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;highProtein&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;protein&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;longLife&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;long life|uht&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;barista&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;barista&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;powder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;powder&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unhomogenised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;unhomogenised&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;condensed|evaporated&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treating silence correctly is the whole trick. If a missing "full cream" counted as a conflict, ALDI's terse titles would stop matching anything; if a missing "lactose free" counted as agreement, every lactose-free product pairs with its ordinary twin at a fictional discount.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The chain lists one product twice and you pay for both
&lt;/h2&gt;

&lt;p&gt;Not a matching problem — a billing one. Woolworths' search API accepts only a few page sizes, so a 20-row request is served as two pages of 12, sorted by relevance, and relevance is not stable across pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows 20
DUP 2 ('208064', 'a2 Milk Full Cream 2L')
DUP 2 ('797407', 'a2 Milk Full Cream 3L')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stockcodes 208064 and 797407 came back at positions 1 and 2, then again at 19 and 20. Twenty rows, eighteen products, charged as twenty — this Actor bills per row. And a second identical Woolworths row cannot join the group its twin already occupies, so it becomes a bogus &lt;code&gt;single&lt;/code&gt;: two of my 38 rows were that. The fix goes where rows are counted and charged:&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;idKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;seenIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duplicates&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run summary reports &lt;code&gt;duplicatesDropped&lt;/code&gt;, so nobody has to take my word for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same input, after the three fixes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;productsSeen: 46 · duplicatesDropped: 2 · rowsDelivered: 33 · 6 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;38 rows became 33, multi-chain rows went 7 → 9, four of them with all three chains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Coles&lt;/th&gt;
&lt;th&gt;Woolworths&lt;/th&gt;
&lt;th&gt;ALDI&lt;/th&gt;
&lt;th&gt;Match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1000 ml house brand&lt;/td&gt;
&lt;td&gt;1.75&lt;/td&gt;
&lt;td&gt;1.75&lt;/td&gt;
&lt;td&gt;1.75&lt;/td&gt;
&lt;td&gt;own-brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000 ml house brand&lt;/td&gt;
&lt;td&gt;3.40&lt;/td&gt;
&lt;td&gt;3.40&lt;/td&gt;
&lt;td&gt;3.39&lt;/td&gt;
&lt;td&gt;own-brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3000 ml house brand&lt;/td&gt;
&lt;td&gt;4.95&lt;/td&gt;
&lt;td&gt;4.95&lt;/td&gt;
&lt;td&gt;4.95&lt;/td&gt;
&lt;td&gt;own-brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000 ml a2&lt;/td&gt;
&lt;td&gt;7.30&lt;/td&gt;
&lt;td&gt;7.30&lt;/td&gt;
&lt;td&gt;7.29&lt;/td&gt;
&lt;td&gt;same-brand&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fewer rows, more answers. And the answer is worth noticing: on house-brand full cream milk the three chains sit within one cent of each other at every size, so the cheapest-chain question is settled by a rounding error. The money is in brands and in whatever is marked down that morning — Farmhouse Gold at 13%, Zymil 2L at 14% once it meets the right Coles product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields that are empty on purpose
&lt;/h2&gt;

&lt;p&gt;Three chains, three different silences. &lt;strong&gt;ALDI publishes no online stock&lt;/strong&gt; — &lt;code&gt;inStock&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;availability&lt;/code&gt; reads &lt;code&gt;In stores&lt;/code&gt;, so filtering on &lt;code&gt;inStock === true&lt;/code&gt; drops every ALDI row. &lt;strong&gt;&lt;code&gt;availableQuantity&lt;/code&gt; exists only at Coles&lt;/strong&gt;, and with no store number it is a warehouse figure (2,224 for the house-brand 3L), not shelf stock. &lt;strong&gt;A row can have no price at all&lt;/strong&gt;: Woolworths returned "Dairy Farmers Full Cream Milk 1L" with &lt;code&gt;price: null&lt;/code&gt;, &lt;code&gt;wasPrice: 3.00&lt;/code&gt;, &lt;code&gt;inStock: false&lt;/code&gt;, and Coles returned "Geelong Full Cream Milk" with no price while still saying &lt;code&gt;InStoreAndOnline&lt;/code&gt; — so a comparison row can legitimately carry &lt;code&gt;cheapestChain: null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more thing that is not a bug: ALDI's search is a text match over the whole catalogue. Ask it for &lt;code&gt;milk&lt;/code&gt; and 8 of its 30 rows are Maltesers, a Cadbury block, milk arrowroot biscuits and a milk frother. Coles returned 30 of 30 inside &lt;code&gt;Milk&lt;/code&gt; and &lt;code&gt;Long-Life Milk&lt;/code&gt;, Woolworths 30 of 30 inside &lt;code&gt;DAIRY - MILK&lt;/code&gt; and &lt;code&gt;BEVERAGES&lt;/code&gt;. The relevance filter you want is the chain's own &lt;code&gt;category&lt;/code&gt;, in three vocabularies: &lt;code&gt;Milk / Full Cream Milk&lt;/code&gt;, &lt;code&gt;DAIRY - MILK&lt;/code&gt;, &lt;code&gt;Dairy, Eggs &amp;amp; Fridge / Milk&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;No key for the sources, no browser:&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~au-grocery-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;keywords&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;full cream milk&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;chains&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;coles&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;woolworths&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;aldi&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;output&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;comparison&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;maxProductsPerKeyword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lives here: &lt;a href="https://apify.com/lergassy/au-grocery-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Coles, Woolworths &amp;amp; ALDI Scraper&lt;/a&gt;. Give a run a &lt;code&gt;watchName&lt;/code&gt; and schedule it, and later runs return only what moved — the mode I actually use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to reach for it.&lt;/strong&gt; Prices are the national online prices each chain shows a visitor with no store selected; store-level and delivery-zone pricing is not in there. Woolworths' category service refuses anonymous requests, so whole-aisle sweeps work at Coles and ALDI only. And if you only ever need one chain, a single-chain scraper is less machinery.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: when you join sources that never agreed on a schema, the join key &lt;em&gt;is&lt;/em&gt; the product. Everything else is plumbing — and the plumbing was never what was broken.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>data</category>
      <category>javascript</category>
      <category>api</category>
    </item>
    <item>
      <title>The Same Song in Two TikToks Came Back as 'Made My Night' and 'Made My Life'</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:12:44 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/the-same-song-in-two-tiktoks-came-back-as-made-my-night-and-made-my-life-co3</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/the-same-song-in-two-tiktoks-came-back-as-made-my-night-and-made-my-life-co3</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 Apify Actors, and one of them reads short vertical video: paste TikTok links, Reels, Shorts or a whole profile, get one flat row per video with the transcript, the text burned into the picture, and the first three seconds of both.&lt;/p&gt;

&lt;p&gt;The transcript part sounds solved. Speech recognition is a commodity in 2026 — download the audio, post it to a model, get words. What nobody warns you about is that in short-form video, &lt;strong&gt;the words you get back are frequently not the words the creator said&lt;/strong&gt;, and a plain transcript field cannot tell you which case you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eleven rows from one profile
&lt;/h2&gt;

&lt;p&gt;13 October 2026, profile mode: &lt;code&gt;@duolingo&lt;/code&gt;, ten newest videos, default settings, no on-screen text. Eleven rows, 87 seconds end to end.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;&lt;code&gt;transcriptText&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5.55 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14.63 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Ahem.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.41 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Веритесь Майнкрафта! Ему место в Бэкрус!&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17.12 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;You just made my night, I'm getting better now…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21.57 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Time for typical, but what? You just made my life…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;45.6 s&lt;/td&gt;
&lt;td&gt;platform-captions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Boom! Boom! Boom! Boom! Boom! Can you say happy birthday…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.54 s&lt;/td&gt;
&lt;td&gt;speech-recognition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mama, please take a picture. Yes. Thank you…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rows carrying an actual script by the account that posted them: six of eleven. One row is a full stop, one is a throat clearing, three are lyrics from borrowed sounds, and one is Russian containing two words that are not Russian words.&lt;/p&gt;

&lt;p&gt;That ratio is not a Duolingo quirk. On a labelled set of 26 real videos I measured earlier, only 69% carried a narrated script, 19% had no usable speech, and 12% ran on someone else's trending sound. Treat &lt;code&gt;transcriptText&lt;/code&gt; as "what this creator said" and roughly a third of your rows lie to you — and they pass a spot check, because the rows you scroll past first are the narrated ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same song, two different strings
&lt;/h2&gt;

&lt;p&gt;The two lyric rows are the useful ones: both clips ride the same LE SSERAFIM track, and recognition heard it twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17.12 s → "You just made my night, I'm getting better now You just made my night,
           got me figured out Feelings got me high, oh baby, it's just science"

21.57 s → "Time for typical, but what? You just made my life, I feel better now
           You just made my life, got me figured out Feeling got me high
           Oh baby, it's just science"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same line, two spellings: &lt;em&gt;made my night&lt;/em&gt; and &lt;em&gt;made my life&lt;/em&gt;. The creator's own caption on the second clip reads "made my night, i'm literally an owl" — so we know which is wrong, and we know it only from metadata, never from the audio.&lt;/p&gt;

&lt;p&gt;The practical consequence: &lt;strong&gt;a transcript is not a usable key for borrowed audio.&lt;/strong&gt; Group a feed by &lt;code&gt;transcriptText&lt;/code&gt; to find every clip riding one sound and you get two groups where there should be one. Use the platform's sound ID for that, not text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sources, and the field that names them
&lt;/h2&gt;

&lt;p&gt;Every row carries &lt;code&gt;transcriptSource&lt;/code&gt; with one of two values, and it changes what the row means.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;platform-captions&lt;/code&gt; — TikTok, Instagram or YouTube already had a caption track, so no video was downloaded and no recognition ran. Fast, and free of the audio charge.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;speech-recognition&lt;/code&gt; — no track existed, so the audio went through Whisper large v3 turbo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the run above it was 1 of 11; in a run over the same account two days earlier, 3 of 11. Platform captions on TikTok are the minority case and an unstable one — whether a video has them depends on the creator's upload settings, not on anything you control.&lt;/p&gt;

&lt;p&gt;The two sources also parse differently, which cost me a bug. YouTube's auto-captions arrive &lt;em&gt;rolling&lt;/em&gt;: every cue repeats the previous line so the box can scroll, so without dedup the transcript is three times too long. TikTok and Instagram captions are not rolling, and there the same dedup deletes real repetition — a clip with one line said five times came back with three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_vtt&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rolling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# YouTube: each cue repeats the previous line → drop the repeat.
&lt;/span&gt;    &lt;span class="c1"&gt;# TikTok / Instagram: a repeated line is a repeated line → keep it.
&lt;/span&gt;    &lt;span class="bp"&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;t&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rolling&lt;/span&gt; &lt;span class="ow"&gt;and&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;seen_tail&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One flag, opposite defaults per platform. There is no way to infer it from the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the platform supplies the captions, it supplies the language too
&lt;/h2&gt;

&lt;p&gt;A seven-second clip captioned "proof that tacos are a language" came back like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durationSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;7.22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcriptSource"&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-captions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transcriptText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ta cô ta có ta cô ta cô ta có ta cô cô ta có ta cô"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is TikTok's own caption generator hearing the English word &lt;em&gt;taco&lt;/em&gt; repeated and filing the clip as Vietnamese, tone marks included. My Actor passes it through, because on &lt;code&gt;platform-captions&lt;/code&gt; rows the language label is the platform's — inventing a second opinion would be worse. So &lt;code&gt;language&lt;/code&gt; is a recognition result only on &lt;code&gt;speech-recognition&lt;/code&gt; rows. Elsewhere it is a claim by TikTok, and TikTok is wrong often enough that you should not route anything on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  YouTube Shorts are cheap and arrive pre-mangled
&lt;/h2&gt;

&lt;p&gt;For YouTube the Actor never downloads the video. YouTube serves Shorts audio only through residential traffic, which at $8/GB costs more on a one-minute clip than I charge for the whole row — so the caption track is the only sane path: a few kilobytes of text, back in seconds, no audio charge. A Short with no track of its own gets an explicit free error row instead of a silent loss.&lt;/p&gt;

&lt;p&gt;The catch is formatting. A 40-second MrBeast Short, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Find a subscriber for whatever country\nthat lands on, give them 10 grand.
&amp;gt;&amp;gt; Okay, Indonesia.\n&amp;gt;&amp;gt; Hey, you got to go now.
...
&amp;gt;&amp;gt; Subscribe to Mr. [music] Beast.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three artifacts in one string: &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; as a speaker-change marker, hard newlines wrapped at caption-box width instead of at sentence ends, and &lt;code&gt;[music]&lt;/code&gt; injected &lt;em&gt;mid-phrase&lt;/em&gt; — "Mr. [music] Beast". Strip newlines, markers and bracketed tags before this reaches an LLM or a search index, or your embeddings carry a lot of caption-box geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What recognition says when there is nothing to say
&lt;/h2&gt;

&lt;p&gt;Speech models do not stay quiet over music; they emit their most probable sentence. On wordless clips I have collected &lt;code&gt;"We'll be right back."&lt;/code&gt;, &lt;code&gt;"Thanks for watching"&lt;/code&gt;, and on a music-only TikTok the Korean &lt;code&gt;"구독과 좋아요 부탁드립니다."&lt;/code&gt; — "please subscribe and like". The model's own confidence is no help: on one branded clip it reported a false-positive probability of 0.00 for a sentence nobody said.&lt;/p&gt;

&lt;p&gt;So there is a filter — short output matching a list of stock lines and music tags becomes an empty transcript, the row is marked as carrying no speech, and the audio is not billed. It works on sentences. It does not work on &lt;code&gt;.&lt;/code&gt;: that 5.55-second row above is a silent Taco Bell promo whose transcript is one period, and &lt;code&gt;speechDetected&lt;/code&gt; came back &lt;code&gt;true&lt;/code&gt; because the string is not empty. Punctuation-only output is the next entry on that list. Until then, treat a transcript with no alphanumeric characters as no speech.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard that costs six cents
&lt;/h2&gt;

&lt;p&gt;The Actor can label all of this per row — &lt;code&gt;contentClass&lt;/code&gt; of &lt;code&gt;narrated&lt;/code&gt;, &lt;code&gt;borrowed_audio&lt;/code&gt;, &lt;code&gt;silent&lt;/code&gt; or &lt;code&gt;empty&lt;/code&gt;, plus &lt;code&gt;speechCoverage&lt;/code&gt; and a count of burned-in subtitle tracks. Those fields fill &lt;strong&gt;only when on-screen text reading is enabled&lt;/strong&gt;, because the class is decided by comparing speech against what is written in the frame — which means downloading the video and reading it frame by frame. That is a separate $0.06 event, it was off in the run above, and that is exactly why every &lt;code&gt;contentClass&lt;/code&gt; in it is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the trade is $0.004 per clip for text with no verdict, or $0.064 for text plus a verdict on whose words they are. For a one-off competitor sweep, pay it. For 50,000 rows, don't — filter yourself on word count, repeated lines and punctuation-only strings.&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;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;YOUR_API_TOKEN&amp;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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lergassy/tiktok-reels-shorts-transcript-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&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;profiles&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;@duolingo&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;resultsLimitPerProfile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onlyPostsNewerThan&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;2026-10-01&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;words&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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transcriptText&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;split&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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transcriptSource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firstThreeSecondsSpeech&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/tiktok-reels-shorts-transcript-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;here&lt;/a&gt; — pay-per-event, $0.002 per video plus $0.001 per started 10 seconds of recognised audio, no login, no proxy fee. Wrong tool if you already hold the media file (use a plain transcriber), if you need Instagram &lt;em&gt;profiles&lt;/em&gt; rather than Reel links (those need a login and are not supported), or if you really just want engagement numbers — the rows carry views and likes, but a metrics scraper gets them for a fraction of the price.&lt;/p&gt;

&lt;p&gt;One thing to take from this: in short video, log where every transcript came from. The words are the easy part.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>api</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>A Walkover, a Retirement and a Straight-Sets Win All Arrive With the Same Status Code</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 05:11:55 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/a-walkover-a-retirement-and-a-straight-sets-win-all-arrive-with-the-same-status-code-37km</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/a-walkover-a-retirement-and-a-straight-sets-win-all-arrive-with-the-same-status-code-37km</guid>
      <description>&lt;p&gt;Visas pay my bills — I run an agency in Bali. The other half of my week goes into Apify Actors, and one of them is a tennis feed: pick a date and a set of tours, get one flat row per match with both players, set scores, tiebreaks and a one-line summary.&lt;/p&gt;

&lt;p&gt;I expected the hard part to be blocking. It isn't — the whole professional day of tennis arrives in a single HTTP request, no browser, no key, and a run finishes in under ten seconds. The hard part turned out to be a question that sounds too simple to be a problem: &lt;strong&gt;is this match over?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  80 rows from yesterday
&lt;/h2&gt;

&lt;p&gt;Here is a real run, taken this morning (13 October 2026): yesterday's results, every tour, capped at 80 rows. 8.1 seconds end to end. The tour mix tells you where professional tennis actually happens on a Monday — 38 Challenger men, 20 Challenger women, 14 ITF men, 4 WTA, 4 ATP.&lt;/p&gt;

&lt;p&gt;Breakdown by status:&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;status&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;finished&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;retired&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;walkover&lt;/code&gt;&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;Seven of eighty — 8.75% — did not end the way a results table assumes. And every single one of those seven carries &lt;code&gt;statusCode: 3&lt;/code&gt;. The same number as all 73 completed matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fields, and the obvious one is useless
&lt;/h2&gt;

&lt;p&gt;The feed carries two state keys per match. The first is coarse: &lt;code&gt;1&lt;/code&gt; scheduled, &lt;code&gt;2&lt;/code&gt; live, &lt;code&gt;3&lt;/code&gt; no longer scheduled. The second is the precise stage, and it is a 70-value enum — &lt;code&gt;finished&lt;/code&gt;, &lt;code&gt;retired&lt;/code&gt;, &lt;code&gt;walkover&lt;/code&gt;, &lt;code&gt;awarded&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;, &lt;code&gt;postponed&lt;/code&gt;, &lt;code&gt;abandoned&lt;/code&gt;, &lt;code&gt;suspended&lt;/code&gt;, plus live stages down to &lt;code&gt;thirdSetTiebreak&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A cancelled match, a walkover, a retirement and a 6-4 6-2 win are all "no longer scheduled". The coarse field cannot tell them apart. The first version of my mapper read only that field, because it is the one that looks like the status field, and it labelled &lt;strong&gt;ten matches out of twenty-eight as finished with no score at all&lt;/strong&gt;. A results table where a third of the rows are completed matches with an empty score is worse than no table, and it looks fine in a spot check because the first rows you scroll past are real.&lt;/p&gt;

&lt;p&gt;So the mapper reads the precise stage and keeps it in the row twice: &lt;code&gt;status&lt;/code&gt; for the stage name a human filters on, and &lt;code&gt;played&lt;/code&gt; — a boolean that is true for exactly four stages:&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;DONE_STAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finished&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;walkover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;awarded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;played&lt;/code&gt; is the field to filter on, not &lt;code&gt;status == "finished"&lt;/code&gt;. Retirements and walkovers produced a winner and belong in a results table; filtering on &lt;code&gt;finished&lt;/code&gt; silently drops 8.75% of yesterday's results, and those are not random — a disproportionate share of retirements happen at Challenger and ITF level, where bodies are held together with tape and a flight the next morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreline that cannot exist
&lt;/h2&gt;

&lt;p&gt;Here is one of the retirements, from the Catania Challenger:&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;"matchId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vy2Rjhqo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CHALLENGER MEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tournament"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Catania"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"retired"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusStage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"retired"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusStageCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"played"&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;"homePlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cuenin S."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"awayPlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Almazan Valiente I."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"homeSets"&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;"awaySets"&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;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7-6(4) 3-6 3-4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"winner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"away"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"winnerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Almazan Valiente I."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cuenin S. vs Almazan Valiente I., CHALLENGER MEN Catania — Almazan Valiente I. won after a retirement 7-6(4) 3-6 3-4"&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;Read &lt;code&gt;score&lt;/code&gt; and &lt;code&gt;homeSets&lt;/code&gt;/&lt;code&gt;awaySets&lt;/code&gt; without reading &lt;code&gt;status&lt;/code&gt; and you have a man who won a best-of-three match one set to one, trailing 3-4 in the decider. Feed that into a sets-won model and it is not an outlier you can clip — it is a legal-looking record that drags every average it touches. Another one in the same run: &lt;code&gt;6-2 2-0&lt;/code&gt;, a match "won" in a set and a bit.&lt;/p&gt;

&lt;p&gt;The walkovers are the opposite failure. &lt;code&gt;score&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;sets&lt;/code&gt; is &lt;code&gt;[]&lt;/code&gt;, &lt;code&gt;homeSets&lt;/code&gt; and &lt;code&gt;awaySets&lt;/code&gt; are &lt;code&gt;null&lt;/code&gt; — but &lt;code&gt;winner&lt;/code&gt; is set and &lt;code&gt;played&lt;/code&gt; is true:&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;"matchId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hb2HZnSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ATP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tournament"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shanghai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"walkover"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"statusStageCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"played"&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;"homePlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Schnaitter J./Wallner M."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"awayPlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Buse I./Vallejo D."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&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;"sets"&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;"homeSets"&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;"awaySets"&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;"winner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"home"&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 row with a winner and no score is correct here. If your pipeline treats a null score as a scrape failure, it will retry forever on a match that never started.&lt;/p&gt;

&lt;p&gt;The mirror image shows up on the schedule side. Today's ATP and WTA card came back as 30 rows: 26 &lt;code&gt;upcoming&lt;/code&gt;, 1 &lt;code&gt;walkover&lt;/code&gt;, and &lt;strong&gt;3 &lt;code&gt;canceled&lt;/code&gt;&lt;/strong&gt; — Bencic vs Zhang, Kalinskaya vs Zhang and Li vs Zheng, all three cancelled from the Wuhan card, all three still sitting in today's schedule feed with a start time. Publish that as "today's matches" and you have promised three matches that will not be played.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four more things the source does, and why they bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The doubles decider is not a set.&lt;/strong&gt; One Wuhan doubles result came back &lt;code&gt;7-5 4-6 3-10&lt;/code&gt;. That third set is a 10-point match tiebreak, and the feed puts those points in the games column like any other set. &lt;code&gt;homeSets: 1, awaySets: 2&lt;/code&gt; is right; summing the games column for "games won" adds thirteen games that were never played.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are two player ids and only one of them is stable.&lt;/strong&gt; The feed carries a per-match entry id — different for the same player in two different matches — and a participant id that the search endpoint returns and the player page URL is built from. My first version used the entry id. Every player link it emitted answered 404 and every id failed to match a search result, which is exactly the class of bug that passes code review: the field is populated, the shape is right, the value is garbage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statistics are strings, and the names repeat.&lt;/strong&gt; A played match carries around 58 statistic rows, in sections &lt;code&gt;Match&lt;/code&gt;, &lt;code&gt;Set 1&lt;/code&gt;, &lt;code&gt;Set 2&lt;/code&gt;, grouped into Service, Return, Points and Games. Of the 116 values in that run, 66 were shaped &lt;code&gt;74% (29/39)&lt;/code&gt;, 32 were plain integers, 12 were fractions like &lt;code&gt;6/7&lt;/code&gt; and 6 were bare percentages. All strings. And the &lt;code&gt;name&lt;/code&gt; is not unique — &lt;code&gt;Aces&lt;/code&gt; appears three times in one match:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Home&lt;/th&gt;
&lt;th&gt;Away&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Match&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set 1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set 2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the obvious flatten:&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;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="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;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;home&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statistics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;   &lt;span class="c1"&gt;# wrong
&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# "3" — the last set, not the match
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key on &lt;code&gt;(section, group, name)&lt;/code&gt; or filter to &lt;code&gt;section == "Match"&lt;/code&gt; first. Nothing errors if you don't; you just get the final set's number labelled as the match total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point-by-point only names the server twice per set.&lt;/strong&gt; The block for that match held 2 sets and 18 games. Four of the 18 carried a &lt;code&gt;server&lt;/code&gt; value — the first two games of each set. Tennis alternates serve, so the rest are derivable, but you have to do it yourself, and you have to do it per set because the order flips. One more detail worth knowing before you parse the point strings: they are always written home:away, not server:receiver, so &lt;code&gt;0:15&lt;/code&gt; in a game served by the away player means the server won the point. The strings also carry inline markers like &lt;code&gt;|B1|&lt;/code&gt;; I pass those through verbatim rather than invent a meaning for them.&lt;/p&gt;

&lt;p&gt;Head-to-head has a related trap that I do fix in the mapper: the winner of each previous meeting is not a flag, it is a leading asterisk on the winning player's name. Left in place it quietly corrupts every join on player names, so it gets stripped into a proper &lt;code&gt;winner&lt;/code&gt; field. The h2h scores are set counts only (&lt;code&gt;2:0&lt;/code&gt;), not game scores.&lt;/p&gt;

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

&lt;p&gt;One POST, no key for the source, pay per row:&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~tennis-scores-api/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;mode&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;results&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;tours&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;all&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;daysBack&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;maxMatches&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&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;real&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;played&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;clean&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;real&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;status&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;finished&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# completed only
&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;rows&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;real&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;clean&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                 &lt;span class="c1"&gt;# 80 80 73
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's on the Apify Store as &lt;a href="https://apify.com/lergassy/tennis-scores-api?fpr=wmeplu" rel="noopener noreferrer"&gt;Tennis Scores &amp;amp; Stats API&lt;/a&gt; — schedule, live, results, single match, player, rankings and search, one flat row per match at $0.0009 a row.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need the round.&lt;/strong&gt; The day feed does not carry it. A schedule row's &lt;code&gt;round&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; rather than a guess reverse-engineered from the draw size, and no amount of filtering will conjure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need odds.&lt;/strong&gt; Not in this source. Different feed, different article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need history.&lt;/strong&gt; This reads the public live-score data for a date range. It is not an archive — if you want yesterday's closing state preserved, schedule your own runs now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are a broadcaster.&lt;/strong&gt; The official tour feeds are licensed, come with an SLA and cost thousands a month. This is public live-score data turned into rows, and it is priced accordingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The portable lesson has nothing to do with tennis: when a feed gives you two fields for the same concept, the one that is easy to read is usually the one that was easy to write — a three-value bucket somebody added for colouring a web page. Find the enum underneath it and spend a day mapping it properly. The cost of getting it wrong is not an error in your logs. It is 8.75% of your rows being confidently, plausibly false.&lt;/p&gt;

</description>
      <category>api</category>
      <category>data</category>
      <category>python</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Kalshi's Market Titled 'HIGHEST Temperature SATX' Settles in Louisville</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 21:11:30 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/kalshis-market-titled-highest-temperature-satx-settles-in-louisville-22eh</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/kalshis-market-titled-highest-temperature-satx-settles-in-louisville-22eh</guid>
      <description>&lt;p&gt;Visas are my day job — I run an agency in Bali. Scrapers are the other half of the week, and one of them reads Kalshi's daily temperature markets: the ladders of brackets where a contract pays $1 if tomorrow's high in a given city lands in a given two-degree band. The prices are a crowd-sourced probability distribution over the weather, which is a genuinely interesting dataset and free to read — Kalshi's market API needs no key and no account.&lt;/p&gt;

&lt;p&gt;I thought the hard part would be the bracket arithmetic. It wasn't. The hard part was answering the question &lt;em&gt;which city is this market about&lt;/em&gt;, and the reason is that Kalshi answers it three times, in three places, and they do not always agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three location fields, two of them unreliable
&lt;/h2&gt;

&lt;p&gt;Here are three live calls against the public API, made today, 12 October 2026. First the series list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://api.elections.kalshi.com/trade-api/v2/series?category=Climate%20and%20Weather'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of the 422 entries it returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KXHIGHTSDF   | HIGHEST Temperature SATX
KXHIGHTSATX  | San Antonio Daily Maximum Temperature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The series whose ticker ends in &lt;code&gt;SDF&lt;/code&gt; — the IATA code for Louisville — is titled with &lt;code&gt;SATX&lt;/code&gt;, San Antonio. Now the event under that same series:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://api.elections.kalshi.com/trade-api/v2/events?series_ticker=KXHIGHTSDF&amp;amp;with_nested_markets=true&amp;amp;status=open&amp;amp;limit=1'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event  KXHIGHTSDF-26OCT12 | Highest temperature in Louisville on Oct 12, 2026?
rules: If the maximum temperature recorded at Louisville (CLISDF) for Oct 12, 2026,
       is less than 82° fahrenheit according to The Weather Company, then the market
       resolves to Yes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Series title says San Antonio. Event title says Louisville. Settlement rules say Louisville, station &lt;code&gt;CLISDF&lt;/code&gt;. Its sibling &lt;code&gt;KXLOWTSDF&lt;/code&gt; is titled "Lowest Temperature SDX", which is not a code for anywhere.&lt;/p&gt;

&lt;p&gt;So: the only field that is load-bearing is the one inside the resolution rules, because that is the string the exchange pays out against. Everything above it is a label a human typed. My parser is one line, and it reads the rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stationFromRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rules&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="o"&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\(([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{3}[&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{2,4})\)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;m&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="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The city then comes out of a station table, never out of the title. The title is a fallback label and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the station, not the city, decides your forecast
&lt;/h2&gt;

&lt;p&gt;If you only want prices this is trivia. The moment you want to put a weather forecast next to the price, it becomes the whole problem — because these markets do not settle on "the weather in Chicago". They settle on one climate report from one point on the map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Market&lt;/th&gt;
&lt;th&gt;Settles at&lt;/th&gt;
&lt;th&gt;Not at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chicago&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLIMDW&lt;/code&gt; — Midway, southwest side&lt;/td&gt;
&lt;td&gt;O'Hare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Los Angeles&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLILAX&lt;/code&gt; — the airport, by the ocean&lt;/td&gt;
&lt;td&gt;downtown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Houston&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLIHOU&lt;/code&gt; — Hobby&lt;/td&gt;
&lt;td&gt;Intercontinental (&lt;code&gt;CLIIAH&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New York City&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLINYC&lt;/code&gt; — Central Park&lt;/td&gt;
&lt;td&gt;Newark or LaGuardia&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Newark and Trenton are their own markets, by the way — &lt;code&gt;KXHIGHTEWR&lt;/code&gt; and &lt;code&gt;KXHIGHTTTN&lt;/code&gt;. "New York weather" is three separate ladders.&lt;/p&gt;

&lt;p&gt;I measured what guessing costs. Same forecast provider (Open-Meteo, also keyless), two coordinate pairs each, daily high for the next three days, pulled this afternoon:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;City&lt;/th&gt;
&lt;th&gt;Settlement station&lt;/th&gt;
&lt;th&gt;The obvious alternative&lt;/th&gt;
&lt;th&gt;Biggest gap in 3 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Los Angeles&lt;/td&gt;
&lt;td&gt;LAX 77.6° (14 Oct)&lt;/td&gt;
&lt;td&gt;downtown 81.8°&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.2°&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;San Francisco&lt;/td&gt;
&lt;td&gt;SFO 72.5° (13 Oct)&lt;/td&gt;
&lt;td&gt;downtown 69.1°&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.4°&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Houston&lt;/td&gt;
&lt;td&gt;Hobby 93.4° (13 Oct)&lt;/td&gt;
&lt;td&gt;Intercontinental 90.3°&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1°&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chicago&lt;/td&gt;
&lt;td&gt;Midway 66.2° (14 Oct)&lt;/td&gt;
&lt;td&gt;the Loop 63.3°&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9°&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Phoenix and Washington stayed inside half a degree, so this is not a uniform correction you can shrug off — it is coastal and lake-effect geography, and it is biggest exactly where the volume is. Kalshi's brackets are &lt;strong&gt;two degrees wide&lt;/strong&gt;. A 4.2° error is two whole brackets. You would be comparing the market's price for "77° to 78°" against a forecast that belongs in "81° or above" and calling it an edge.&lt;/p&gt;

&lt;p&gt;Observations have the same trap, and a nice shortcut out of it: Kalshi's climate-report code maps onto the NWS station id by swapping the &lt;code&gt;CLI&lt;/code&gt; prefix for &lt;code&gt;K&lt;/code&gt;. &lt;code&gt;CLIMDW&lt;/code&gt; → &lt;code&gt;KMDW&lt;/code&gt;, and &lt;code&gt;https://api.weather.gov/stations/KMDW/observations&lt;/code&gt; gives you what the settling station has actually recorded, free. (Settlement itself is "according to The Weather Company" per the rules, so treat NWS obs as a sanity check on the same thermometer, not as the official number.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that tells you the forecast is stale, not smart
&lt;/h2&gt;

&lt;p&gt;That observation column turned out to be the most useful thing in the output. A real row from a run I made an hour ago, Chicago high for today:&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;"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;"Chicago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"station"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CLIMDW"&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-10-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"favourite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"88° to 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;"favouriteProbability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"marketExpected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;88.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forecastValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;86.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;"forecastMinusMarket"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-2.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;"forecastBracket"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"86° to 87°"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forecastBracketProbability"&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;"observedHigh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"observedLow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;62.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"volume"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;100723.06&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;Read the first five fields and you have found a disagreement: the model says 86.2, the market is 99% certain of 88–89, and the bracket the model points at is on sale for one cent. Read &lt;code&gt;observedHigh&lt;/code&gt; and the story collapses — Midway had &lt;strong&gt;already recorded 88°&lt;/strong&gt; when I pulled this. The high was in. The market wasn't confident, it was informed; the model run was old.&lt;/p&gt;

&lt;p&gt;Any forecast-versus-market row for a day already under way needs that column, or you will mistake lag for alpha. For tomorrow, where there is nothing recorded yet, the same actor's Chicago row shows a real gap: market expects 82.2, model says 77.0, and the bracket the model picks is the favourite at 52% — a disagreement about &lt;em&gt;magnitude&lt;/em&gt; inside a band everyone agrees on.&lt;/p&gt;

&lt;p&gt;One more honesty flag in that example. Tomorrow's favourite is &lt;code&gt;"81° or below"&lt;/code&gt;, an open-ended end bracket with no true midpoint. When most of the money sits in one of those, a weighted average of midpoints is capped by construction and understates the market. That row comes back with &lt;code&gt;marketExpectedIsBounded: true&lt;/code&gt; so you know not to quote the number as a temperature. Use &lt;code&gt;forecastBracketFairProbability&lt;/code&gt; instead — what the market charges for the bracket your model actually picked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Days nobody traded
&lt;/h2&gt;

&lt;p&gt;Kalshi opens an event per city per day whether or not anyone shows up. In yesterday's full snapshot — 48 open events, 288 brackets, 24 cities, highs and lows — &lt;strong&gt;9 of the 48 ladders priced to less than 0.85 in total&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;Chicago    low   sum 0.46
San Diego  low   sum 0.58
Seattle    low   sum 0.64
Minneapolis low  sum 0.69
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy six-bracket ladder sums to a little over 1.00 — the surplus is the exchange's cut. These sum to half of 1.00, because every bracket is sitting at a penny of stale quote on a few dollars of volume. Scale those six pennies to 100% and you have manufactured a confident-looking distribution out of nothing; one of those rows reports an "overround" of −0.31, which is not a tight market, it is an empty one. So they come back flagged &lt;code&gt;illiquid: true&lt;/code&gt;, fair probabilities withheld rather than invented, and the forecast mode drops them entirely. Overnight lows are where this clusters — nobody trades them, and that is the data, not a bug.&lt;/p&gt;

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

&lt;p&gt;Input is four fields; this is the comparison mode for two cities, one day ahead:&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~kalshi-weather-scraper/run-sync-get-dataset-items?token=&amp;lt;TOKEN&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"mode":"forecast","metrics":["high"],"cities":["Louisville","Chicago"],"days":1,"withForecast":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four rows, 33 seconds, and the Louisville rows are correctly labelled Louisville no matter what the series title says. It's on the Apify Store as &lt;a href="https://apify.com/lergassy/kalshi-weather-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Kalshi Weather Markets&lt;/a&gt; — brackets, days, forecast-versus-market and the raw series list, pay per row.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want to trade from it.&lt;/strong&gt; It places no orders and holds no positions. It reads public prices and public weather.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want weather outside the US.&lt;/strong&gt; The series list includes Hong Kong and Berlin temperature series; my station table covers the 24 US cities that settle on a &lt;code&gt;CLI&lt;/code&gt; climate report, and anything else comes back without coordinates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want rain, snow or hurricanes.&lt;/strong&gt; Those series exist in quantity — 30 rain, 23 hurricane, 9 snow in today's list — and usually have no open event. Of the 104 daily high/low series, only 48 had a live event yesterday. Daily highs and lows are what actually trades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to run it from a laptop in the wrong country.&lt;/strong&gt; Kalshi's API refuses connections from a number of regions, Indonesia among them. That is the honest reason this one lives on a cloud platform instead of my machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The portable lesson has nothing to do with prediction markets: when a feed describes the same thing in several fields, find out which field the money is settled against, and parse that one. Here it was a sentence of English prose, three layers down, contradicting the title at the top.&lt;/p&gt;

</description>
      <category>api</category>
      <category>data</category>
      <category>javascript</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Three Books Said 64.3%, 63.1% and 62.0% on the Same Team. Strip the Vig and They Agree to 0.26 Points</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:11:51 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/three-books-said-643-631-and-620-on-the-same-team-strip-the-vig-and-they-agree-to-026-57ga</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/three-books-said-643-631-and-620-on-the-same-team-strip-the-vig-and-they-agree-to-026-57ga</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 is an odds feed: pick leagues and dates, get one row per game × market × book, with DraftKings, Pinnacle and the Kalshi prediction market side by side.&lt;/p&gt;

&lt;p&gt;The question I get asked most about it is some version of "which book is right". The question people should ask first is "are these three numbers even on the same scale". They are not, and the gap is bigger than most of the disagreements you will go hunting for.&lt;/p&gt;

&lt;p&gt;Here is a run from today — 12 October 2026, NFL, four days ahead, moneyline, spread and total, three sources. Fifteen rows, two games, 2.4 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same team, three implied probabilities
&lt;/h2&gt;

&lt;p&gt;Monday night, Buffalo Bills at Los Angeles Rams. Moneyline, Rams to win:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Book&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Implied probability&lt;/th&gt;
&lt;th&gt;Overround&lt;/th&gt;
&lt;th&gt;Fair probability&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DraftKings&lt;/td&gt;
&lt;td&gt;−180&lt;/td&gt;
&lt;td&gt;64.29%&lt;/td&gt;
&lt;td&gt;4.29%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.64%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinnacle&lt;/td&gt;
&lt;td&gt;−171&lt;/td&gt;
&lt;td&gt;63.10%&lt;/td&gt;
&lt;td&gt;2.47%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.58%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kalshi&lt;/td&gt;
&lt;td&gt;−163&lt;/td&gt;
&lt;td&gt;62.00%&lt;/td&gt;
&lt;td&gt;1.00%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.38%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the third column and the three books look 2.29 percentage points apart on who wins a football game. Read the fifth and they are 0.26 points apart — closer than the rounding on most odds screens.&lt;/p&gt;

&lt;p&gt;Nothing moved between those two columns except arithmetic. Implied probability is just &lt;code&gt;1 / decimal_odds&lt;/code&gt;, and for a two-sided market the two implied probabilities sum to more than 100%. That excess is the overround — the bookmaker's margin, the vig, the cut. DraftKings' moneyline added up to 104.29%. Pinnacle's to 102.47%. Kalshi's to 101.00%.&lt;/p&gt;

&lt;p&gt;Scale each side down by the sum and you get the fair, vig-free probability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fair_home = implied_home / (implied_home + implied_away)
61.64%    = 0.6429 / 1.0429
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare raw implied probabilities across books and you are mostly measuring which book charges more, not which book thinks differently. DraftKings looks like the most confident in the Rams in that table. It isn't. It is the most expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  De-vigging does not erase disagreement — it isolates it
&lt;/h2&gt;

&lt;p&gt;The second game in the run, Seattle Seahawks at Denver Broncos, three days out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Book&lt;/th&gt;
&lt;th&gt;Broncos price&lt;/th&gt;
&lt;th&gt;Implied&lt;/th&gt;
&lt;th&gt;Fair&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DraftKings&lt;/td&gt;
&lt;td&gt;+130&lt;/td&gt;
&lt;td&gt;43.48%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41.70%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinnacle&lt;/td&gt;
&lt;td&gt;+142&lt;/td&gt;
&lt;td&gt;41.32%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.12%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kalshi&lt;/td&gt;
&lt;td&gt;+150&lt;/td&gt;
&lt;td&gt;40.00%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.00%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Naive gap: 3.48 points. Fair gap: 1.70 points. Half the apparent disagreement was margin, and half of it was real — DraftKings genuinely rates Denver higher than the other two do. That survivor is the part worth modelling.&lt;/p&gt;

&lt;p&gt;That is why the rows carry both numbers. &lt;code&gt;homeImpliedProbability&lt;/code&gt; is what the price literally says. &lt;code&gt;homeFairProbability&lt;/code&gt; and &lt;code&gt;overround&lt;/code&gt; are what you need to compare anything to anything. Ship only the first and every cross-book chart is wrong by the margin spread; ship only the second and nobody can reconstruct the price.&lt;/p&gt;

&lt;p&gt;Six DraftKings rows in this run ran 4.26% to 4.75% overround. Six Pinnacle rows ran 2.41% to 4.01%. One Kalshi row came back at −0.01%, which is not a bug and not free money: Kalshi is an exchange, the price I quote is the ask on each side, and two asks can sum to a hair under 100% when the book is tight. There is no bookmaker margin there at all, only a bid-ask spread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that says how much the sharp book means it
&lt;/h2&gt;

&lt;p&gt;Pinnacle's public feed carries something nobody else publishes: the risk limit it will accept on each market. Same run, same two games:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;Moneyline limit&lt;/th&gt;
&lt;th&gt;Spread limit&lt;/th&gt;
&lt;th&gt;Total limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bills at Rams (tonight)&lt;/td&gt;
&lt;td&gt;$50,000&lt;/td&gt;
&lt;td&gt;$75,000&lt;/td&gt;
&lt;td&gt;$10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seahawks at Broncos (Thursday)&lt;/td&gt;
&lt;td&gt;$3,000&lt;/td&gt;
&lt;td&gt;$5,000&lt;/td&gt;
&lt;td&gt;$3,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A number a book will take $75,000 on and a number it will take $3,000 on are not the same kind of fact, even though they arrive in the same JSON shape. The limit is a confidence and liquidity signal rolled into one, and it is the honest antidote to treating every row as equally solid.&lt;/p&gt;

&lt;p&gt;One caveat before you build on it: part of that spread is time, because Pinnacle raises limits as kickoff approaches and one of these games starts tonight while the other is Thursday. Within a single game the ordering is cleaner — the Rams spread takes $75,000 and the Rams total takes $10,000, so the book is willing to risk 7.5× as much on the spread as on the points.&lt;/p&gt;

&lt;p&gt;Kalshi's independent liquidity numbers agree about which market is real: $2,740,094 traded on the Monday-night game, $84,989 on Thursday's. Two unrelated venues, same verdict, 32× apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cross-book edge actually looks like
&lt;/h2&gt;

&lt;p&gt;With two or more books on the same market the Actor emits &lt;code&gt;edge&lt;/code&gt; rows. Across 14 odds rows this run produced exactly one, and zero arbitrage:&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;"edge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"game"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Buffalo Bills at Los Angeles Rams"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"market"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;54.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"side"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"under"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"book"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DraftKings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decimal"&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.98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"impliedProbability"&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.5051&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fairProbability"&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.5114&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sharpBook"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pinnacle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"edgePercent"&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.257&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sharpLimitUsd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"booksCount"&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="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arithmetic, end to end: Pinnacle priced that total over −101 / under −111, which is 1.990 and 1.901 in decimal, implying 50.25% and 52.61% — 102.86% together. Scale down and the sharp book's true read on the under is 51.14%. DraftKings paid −102 on the same under, decimal 1.98. Multiply: 0.5114 × 1.98 = 1.0126, so that price returns 1.26% more than the sharp line says it is worth.&lt;/p&gt;

&lt;p&gt;Note &lt;code&gt;sharpLimitUsd: 10000&lt;/code&gt; on the row. A 1.26% edge against a number Pinnacle will take $10,000 on is a different proposition from a 1.26% edge against a number it will take $500 on, so the limit rides along.&lt;/p&gt;

&lt;p&gt;And note what did &lt;strong&gt;not&lt;/strong&gt; happen. The other game's totals were DraftKings 41.5 and Pinnacle 42.5. A full point apart, so they were never compared. Grouping is by market &lt;em&gt;and&lt;/em&gt; line — &lt;code&gt;total|54.5&lt;/code&gt;, not &lt;code&gt;total&lt;/code&gt; — because "over at 41.5" and "over at 42.5" are different bets, and comparing their prices produces a confident, meaningless number. If you are merging odds feeds yourself, that grouping key is the bug you will ship first.&lt;/p&gt;

&lt;p&gt;Arbitrage being empty is the normal answer, by the way. Books watch each other. An empty &lt;code&gt;arbitrage&lt;/code&gt; list means the market is working, not that the scraper failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the potholes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pinnacle throttles rather than queues.&lt;/strong&gt; Bursts come back 401, 403 or 429, not a 503 you can politely retry. Grinding through six growing backoffs on the same address pushed a gateway call past its 48-second timeout once; now it gives up after two quick tries and changes address instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinnacle's feed mixes periods.&lt;/strong&gt; Every matchup ships full-game, half and quarter markets in one list, all with the same shape. Filter on period 0 or your "NFL spreads" table is a third second-half lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opening lines are DraftKings-only.&lt;/strong&gt; &lt;code&gt;openLine&lt;/code&gt;, &lt;code&gt;openHomePrice&lt;/code&gt; and &lt;code&gt;lineMovement&lt;/code&gt; come from DraftKings' own feed. Pinnacle does not publish an open, so those fields are null on its rows — it is a source difference, not a gap. Tonight's Rams spread opened −3 at −111 both ways and is now −3.5 at −102; Pinnacle is still on −3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing stores the past.&lt;/strong&gt; Movement history beyond what DraftKings itself carries means scheduling your own runs now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Calling it is one POST:&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~sports-odds-api/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;mode&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;odds&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;leagues&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;nfl&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;daysAhead&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;markets&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;moneyline&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;spread&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;total&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;books&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;draftkings&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;pinnacle&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;kalshi&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;oddsFormat&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;american&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;minEdgePercent&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;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;24&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="nf"&gt;json&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;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="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;odds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&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;market&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;moneyline&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;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;book&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;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;homePrice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  implied &lt;/span&gt;&lt;span class="si"&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;homeImpliedProbability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;  fair &lt;/span&gt;&lt;span class="si"&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;homeFairProbability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  vig &lt;/span&gt;&lt;span class="si"&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;overround&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need every price tick.&lt;/strong&gt; This is a snapshot per run. Sub-minute movement means a licensed feed with a websocket, not a scraper on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need in-play odds.&lt;/strong&gt; Full-game pre-match markets only. Live lines move faster than any run interval that makes sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need settlement-grade data for a product that pays out.&lt;/strong&gt; Public feeds go stale and get throttled. If money moves on the number, buy the number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a tip service.&lt;/strong&gt; The &lt;code&gt;edge&lt;/code&gt; rows are arithmetic on two prices, nothing more. A 1.26% edge against a $10,000 limit is a measurement, not advice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Actor is &lt;a href="https://apify.com/lergassy/sports-odds-api?fpr=wmeplu" rel="noopener noreferrer"&gt;Sports Betting Odds API&lt;/a&gt; — 28 leagues, props, futures and Kalshi contracts, no API key and no browser. The whole measurement in this post was 15 rows, about four and a half cents.&lt;/p&gt;

&lt;p&gt;Whatever you pull odds from: before you compare two books, make sure the two numbers add up to the same thing.&lt;/p&gt;

</description>
      <category>api</category>
      <category>data</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Booking.com Search Rows Hide 9–16% of the Price in a Field Labelled 21, 22 and 23</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 05:15:39 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/bookingcom-search-rows-hide-9-16-of-the-price-in-a-field-labelled-21-22-and-23-14kd</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/bookingcom-search-rows-hide-9-16-of-the-price-in-a-field-labelled-21-22-and-23-14kd</guid>
      <description>&lt;p&gt;I run a visa agency in Bali. The other half of my week goes into scrapers on Apify, and one of them reads Booking.com search pages: destination in, one row per property out, with the price for your dates, the matched room, the rating and the location.&lt;/p&gt;

&lt;p&gt;The field people build their spreadsheet on is &lt;code&gt;priceTotal&lt;/code&gt;. It is the number Booking prints in the search card, it is the number the site sorts by, and it is not the number anybody pays.&lt;/p&gt;

&lt;p&gt;On 12 October 2026 I ran the same stay — 20 to 23 November 2026, two adults, one room — against four destinations and read all 24 rows. Here is what the excluded-charges field said.&lt;/p&gt;

&lt;h2&gt;
  
  
  24 rows, four cities, nothing tax-inclusive
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;priceTotal&lt;/code&gt; range&lt;/th&gt;
&lt;th&gt;Excluded charges&lt;/th&gt;
&lt;th&gt;Share of the headline price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lisbon (EUR)&lt;/td&gt;
&lt;td&gt;€289.81 – €540.27&lt;/td&gt;
&lt;td&gt;flat €24 + 6.00%&lt;/td&gt;
&lt;td&gt;10.4% – 14.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New York (USD)&lt;/td&gt;
&lt;td&gt;$370.15 – $1438.51&lt;/td&gt;
&lt;td&gt;flat $10.50 + 14.75%&lt;/td&gt;
&lt;td&gt;0% – 16.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokyo (USD)&lt;/td&gt;
&lt;td&gt;$483.42 – $1101.10&lt;/td&gt;
&lt;td&gt;9.01% – 10.00%&lt;/td&gt;
&lt;td&gt;9.0% – 10.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bali (USD)&lt;/td&gt;
&lt;td&gt;$96.09 – $875.27&lt;/td&gt;
&lt;td&gt;9.01% or 10.99%&lt;/td&gt;
&lt;td&gt;9.0% – 11.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the New York column slowly. &lt;code&gt;priceTotal&lt;/code&gt; for the Hyatt Regency Times Square was $1120.95, and the row also carried $175.84 of charges that price does not contain. The guest pays $1296.79. If your rate-shopping table says $1120.95, your table is wrong by the size of a whole night.&lt;/p&gt;

&lt;p&gt;Two of the per-city numbers are suspiciously exact, and that is the useful part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New York: 14.75%, to the cent, on every row that had it.&lt;/strong&gt; $110.06 on $746.20. $138.15 on $936.63. $212.18 on $1438.51. A percentage that stable is a tax rate, not an estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lisbon: a flat €24 on all six properties&lt;/strong&gt;, regardless of a price range that runs from €290 to €540. Three nights, two guests, €24 — €4 per guest per night, charged per stay rather than per euro spent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tokyo came back at 10.00% on five rows and 9.01% on one. Bali is the interesting one: within a single destination, three properties excluded 9.01% and three excluded 10.99%, and which rate applied tracked exactly with the charge code in the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field has amounts but no names
&lt;/h2&gt;

&lt;p&gt;This is what &lt;code&gt;taxesAndFeesExcluded&lt;/code&gt; actually looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hyatt Regency Times Square"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1120.95&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;"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;"taxesAndFeesExcluded"&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;"22: $10.50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"23: $165.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;"taxesAndFeesExcludedTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;175.8402&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chargesInfo"&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="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;&lt;code&gt;22&lt;/code&gt; and &lt;code&gt;23&lt;/code&gt; are not my invention and not a truncation. Booking's GraphQL search response returns excluded charges as a list of objects carrying &lt;code&gt;chargeMode&lt;/code&gt;, &lt;code&gt;chargeInclusion&lt;/code&gt;, &lt;code&gt;chargeType&lt;/code&gt; and &lt;code&gt;amountPerStay&lt;/code&gt; — and for an anonymous caller &lt;code&gt;chargeType&lt;/code&gt; is an integer. There is no &lt;code&gt;translation&lt;/code&gt; node next to it, the way there is on badges, cancellation copy and review text. The site renders "+ taxes and charges" and hides the breakdown behind a tooltip that is assembled elsewhere.&lt;/p&gt;

&lt;p&gt;My first version of this mapper expected names and produced &lt;code&gt;"CITYTAX: US$18.24"&lt;/code&gt; in a code comment that never matched reality. The field was empty on every row for a while, because I was reading a &lt;code&gt;translation&lt;/code&gt; key that is not there.&lt;/p&gt;

&lt;p&gt;Across the four markets the codes behaved consistently enough to describe, though not enough to label:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;22&lt;/strong&gt; was always a flat amount per stay, identical across every property in a city: €24 in Lisbon, $10.50 in New York, $0 in Tokyo and on three Bali rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21&lt;/strong&gt; and &lt;strong&gt;23&lt;/strong&gt; were always a percentage of &lt;code&gt;priceTotal&lt;/code&gt;: 6% in Lisbon, 14.75% in New York, 10% in Tokyo, 9.01% under code 21 and 10.99% under code 23 in Bali.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ship the integers. I am not going to guess that 22 means "city tax" and put a word in a dataset column on the strength of six rows per market, because the moment I do, someone builds a VAT reclaim report on it. An unexplained &lt;code&gt;22&lt;/code&gt; makes you go look. A wrong &lt;code&gt;CITYTAX&lt;/code&gt; does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to a sorted table
&lt;/h2&gt;

&lt;p&gt;The percentage is near-constant inside a city, so within-city ranking mostly survives. I checked all 24 rows: adding the excluded charges did not flip a single pair. Lisbon stayed in the same order; New York stayed in the same order.&lt;/p&gt;

&lt;p&gt;Cross-city comparison is where it breaks. A $500 Tokyo stay really costs about $550. A $500 New York stay really costs about $580. Build a "cheapest city for a three-night trip" chart on &lt;code&gt;priceTotal&lt;/code&gt; and you have quietly handed New York a 6% discount it does not get.&lt;/p&gt;

&lt;p&gt;So compute the honest number before you sort:&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~booking-hotels-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;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&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;destination&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 York&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;checkin&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;2026-11-20&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;checkout&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;2026-11-23&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;adults&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;USD&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;sortBy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&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="nf"&gt;json&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="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&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;priceTotal&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="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;taxesAndFeesExcludedTotal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;true_total&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;priceTotal&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="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;taxesAndFeesExcludedTotal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="si"&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;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;priceTotal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;true_total&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;or 0&lt;/code&gt;. Which brings me to the bug this article found in my own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero that looked like a missing value
&lt;/h2&gt;

&lt;p&gt;West Side YMCA, cheapest row in the New York sort at $370.15, came back like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"taxesAndFeesExcluded"&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;"23: $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;"taxesAndFeesExcludedTotal"&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="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;&lt;code&gt;["23: $0"]&lt;/code&gt; is real: Booking returns the charge line with an amount of zero. But my aggregate was &lt;code&gt;null&lt;/code&gt;, because the mapper ended in &lt;code&gt;|| null&lt;/code&gt; — and in JavaScript &lt;code&gt;0 || null&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. A property with genuinely no extra charges was indistinguishable from a property whose charges I failed to read. On a 25-row New York search, two rows were affected: West Side YMCA and HI New York City Hostel, both hostel-style properties.&lt;/p&gt;

&lt;p&gt;One character, &lt;code&gt;||&lt;/code&gt; to &lt;code&gt;??&lt;/code&gt;, except I removed the fallback entirely since the coercion already yields &lt;code&gt;null&lt;/code&gt; for a missing field. Fixed and live as build 0.1.33 today; those two rows now report &lt;code&gt;0&lt;/code&gt;. If you pulled this Actor's data before 12 October 2026 and treated &lt;code&gt;taxesAndFeesExcludedTotal: null&lt;/code&gt; as "unknown, skip the row", you dropped the cheapest properties in the market.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half of the row is prose, not data
&lt;/h2&gt;

&lt;p&gt;While confirming the tax numbers I changed the &lt;code&gt;language&lt;/code&gt; input and watched the same property come back four ways. MASA Hotel &amp;amp; Spa, Lisbon, same dates:&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;language&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;distanceFromCentre&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;en-us&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2.6 miles from downtown&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;en-gb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2.6 miles from centre&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;de&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Zentrum: 4,2 km&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4,2 km du centre&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things worth knowing before you parse that column:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is a sentence, not a number.&lt;/strong&gt; There is no numeric distance field in the search response — Booking sends &lt;code&gt;location.mainDistance&lt;/code&gt; already rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;English means imperial, even British English.&lt;/strong&gt; &lt;code&gt;en-gb&lt;/code&gt; changed "downtown" to "centre" and left the miles alone. Metric arrives only when you leave English, and then with a comma decimal separator: &lt;code&gt;4,2 km&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wording changes, so your regex is locale-bound.&lt;/strong&gt; &lt;code&gt;de&lt;/code&gt; puts the label first and a colon in the middle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use &lt;code&gt;latitude&lt;/code&gt; and &lt;code&gt;longitude&lt;/code&gt; instead and compute the distance yourself. They are plain floats and they do not translate.&lt;/p&gt;

&lt;p&gt;The same split hits &lt;code&gt;reviewScoreWord&lt;/code&gt;: &lt;code&gt;Wonderful&lt;/code&gt; in English, &lt;code&gt;Sehr gut&lt;/code&gt; in German, &lt;code&gt;Très bien&lt;/code&gt; in French. Filter on that string and your pipeline silently empties the day someone sets a language. Filter on &lt;code&gt;reviewScore&lt;/code&gt;, which stays &lt;code&gt;9.3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What does &lt;em&gt;not&lt;/em&gt; translate is the half of the row my own code assembles: &lt;code&gt;propertyType&lt;/code&gt; and &lt;code&gt;roomBeds&lt;/code&gt; stayed &lt;code&gt;Hotel&lt;/code&gt; and &lt;code&gt;1 king bed&lt;/code&gt; under &lt;code&gt;de&lt;/code&gt; and &lt;code&gt;fr&lt;/code&gt;, because those come from integer→name tables inside the Actor, not from Booking. So a non-English run gives you a row that is half localised and half English, and the seam runs exactly along "who mapped this field". Worth knowing if you ever diff two runs and wonder why only some columns moved.&lt;/p&gt;

&lt;p&gt;One more locale trap: &lt;code&gt;priceTotalText&lt;/code&gt; is a formatted string — &lt;code&gt;"$147.25"&lt;/code&gt; but &lt;code&gt;"€&amp;nbsp;524.51"&lt;/code&gt;, with a non-breaking space and the symbol on the other side. Parse &lt;code&gt;priceTotal&lt;/code&gt;, which is a float.&lt;/p&gt;

&lt;h2&gt;
  
  
  Region searches have no centre
&lt;/h2&gt;

&lt;p&gt;Bali returned &lt;code&gt;destinationType: "REGION"&lt;/code&gt; and &lt;code&gt;distanceFromCentre: null&lt;/code&gt; on all six rows. Lisbon, New York and Tokyo came back as &lt;code&gt;"CITY"&lt;/code&gt; and filled it on every row. That is not a failure — a 5,780 km² island does not have a downtown — but if your dashboard has a "distance from centre" axis, it will be empty for every region, landmark and country-level search you run. Check &lt;code&gt;destinationType&lt;/code&gt; before you plot it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need to actually book.&lt;/strong&gt; This reads public search data. Taking a reservation means Booking's affiliate programme, which is a contract, not a scraper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need yesterday's prices.&lt;/strong&gt; Nothing stores the past. Hotel pricing is only observable going forward, so schedule a daily run now if you want a history in March.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need the exact final figure at checkout.&lt;/strong&gt; The excluded charges are Booking's own estimate of what the property will add; resort fees, card fees and local levies collected at the desk are not all in there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need every rate of 500 hotels.&lt;/strong&gt; Rooms and rates mean a request per property. That is fine for fifty and slow for five hundred.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If reading hotel search data as rows is what you want, the Actor is &lt;a href="https://apify.com/lergassy/booking-hotels-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Booking.com Hotel Search Scraper&lt;/a&gt; — search, single hotel, reviews and a year-ahead price calendar, no browser and no login. The four-city measurement in this post cost 7 cents.&lt;/p&gt;

&lt;p&gt;And whatever you use to get the data: before you sort by price, add the field that says the price is incomplete.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
      <category>data</category>
    </item>
    <item>
      <title>Audio and video to text via API: transcribe a 45-minute podcast for under $0.50</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 02:00:46 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/audio-and-video-to-text-via-api-transcribe-a-45-minute-podcast-for-under-050-nmc</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/audio-and-video-to-text-via-api-transcribe-a-45-minute-podcast-for-under-050-nmc</guid>
      <description>&lt;p&gt;Most speech-to-text APIs make you do the plumbing yourself: download the media, strip the audio from video, cut long files under the size limit, send parts, stitch timings back together. If you just have a list of links — podcast episodes, meeting recordings, lecture videos — that's a lot of glue code for one text file.&lt;/p&gt;

&lt;h2&gt;
  
  
  One link in, one transcript out
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/lergassy/speech-to-text?fpr=wmeplu" rel="noopener noreferrer"&gt;Speech to Text&lt;/a&gt; on Apify takes MP3, M4A, WAV, FLAC, OGG, MP4, MOV or WEBM — any link or uploaded file with sound — and does the whole pipeline: download, audio extraction, splitting, recognition with Whisper large v3, and re-stitching timings. A three-hour recording is one input and one row of output.&lt;/p&gt;

&lt;p&gt;Each row contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the clean transcript with timed passages&lt;/li&gt;
&lt;li&gt;ready SRT or WebVTT subtitles&lt;/li&gt;
&lt;li&gt;retrieval chunks that keep the moment each passage was spoken&lt;/li&gt;
&lt;li&gt;language detected, 90+ languages supported&lt;/li&gt;
&lt;li&gt;optional speaker labels
&lt;/li&gt;
&lt;/ul&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~speech-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/episode-42.mp3"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cost (from 4 October 2026)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;$0.009 per started minute with the built-in recognition key — nothing to sign up for&lt;/li&gt;
&lt;li&gt;$0.004 per minute if you bring your own Groq key (you then pay Groq directly, a fraction of a cent per minute)&lt;/li&gt;
&lt;li&gt;$0.002 per file processed&lt;/li&gt;
&lt;li&gt;speaker labels: +$0.006 per minute, only when switched on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 45-minute episode is about $0.41 with the built-in key (45 × $0.009 + $0.002), or about $0.18 with your own key.&lt;/p&gt;

&lt;p&gt;Video pages (YouTube, TikTok, Instagram, X, Vimeo) whose audio has to be pulled through a residential exit node carry an extra $0.09 per link — direct file links never pay it. For YouTube videos that already have captions, the transcript scraper from &lt;a href="https://dev.to/nikita_iakovlev_415524c19/youtube-has-no-transcript-api-heres-how-to-get-transcripts-for-a-whole-channel-as-rows-for-rag-52dj"&gt;my earlier post on YouTube transcripts&lt;/a&gt; is far cheaper.&lt;/p&gt;

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

&lt;p&gt;Podcast show notes and search, meeting and interview archives, turning a course library into a searchable knowledge base, subtitles for your own videos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It transcribes what is audible: heavy music or crosstalk lowers accuracy, as with any Whisper-based tool.&lt;/li&gt;
&lt;li&gt;Private or login-only media can't be downloaded — use a direct file link or upload the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: links to Apify in this post use my referral code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>speechtotext</category>
      <category>api</category>
      <category>podcast</category>
    </item>
    <item>
      <title>Ten Mercado Libre Results, Four Kinds of Link — and Only Two Carry the Product ID</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Sun, 11 Oct 2026 21:15:05 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/ten-mercado-libre-results-four-kinds-of-link-and-only-two-carry-the-product-id-2g7f</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/ten-mercado-libre-results-four-kinds-of-link-and-only-two-carry-the-product-id-2g7f</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 the newest one reads Mercado Libre — the marketplace that runs 18 separate storefronts across Latin America, each on its own domain and currency: mercadolibre.com.mx, mercadolivre.com.br, mercadolibre.com.ar, .cl, .com.co, and a dozen smaller ones.&lt;/p&gt;

&lt;p&gt;I expected the interesting problem to be the bot gate. It is there, and I will get to it. But the thing that actually cost me rewrites is far more boring and far more likely to break &lt;em&gt;your&lt;/em&gt; code: a Mercado Libre search page does not hand out one kind of link. On 11 October 2026 I ran a 10-result search for &lt;code&gt;zapatillas&lt;/code&gt; on the Argentine site and got back &lt;strong&gt;four distinct URL shapes in ten rows&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four shapes
&lt;/h2&gt;

&lt;p&gt;Positions 1 to 4 — every single one flagged &lt;code&gt;isSponsored: true&lt;/code&gt; — looked like this (truncated; the real thing is about a kilobyte):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://click1.mercadolibre.com.ar/mclics/clicks/external/MLA/count
  ?a=XrvZILjHGldBLoVC1wdirEioJadSenEQxPY7kCoIIJBNUf0R0v3FE0pq…
  &amp;amp;pdp_filters=item_id%3AMLA3183930614
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a product page. It is the ad click counter, and the product it points at is nowhere in the path — the only place the identifier survives is the &lt;code&gt;pdp_filters&lt;/code&gt; query parameter, URL-encoded.&lt;/p&gt;

&lt;p&gt;Positions 5, 7, 8 and 10 were a shape I had not seen in any Mercado Libre scraping write-up:&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.mercadolibre.com.ar/zapatilla-topper-sin-genero-lisa-estandar-rocket-tofunegro/up/MLAU3637728744
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the identifier: &lt;code&gt;MLAU3637728744&lt;/code&gt;. Site prefix &lt;code&gt;MLA&lt;/code&gt;, then a literal &lt;code&gt;U&lt;/code&gt;, then digits. It is not the item ID, and it is not the catalog product ID (&lt;code&gt;/p/MLA…&lt;/code&gt;) that older pages use.&lt;/p&gt;

&lt;p&gt;Positions 6 and 9 were the classic listing URL everybody's regex knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://articulo.mercadolibre.com.ar/MLA-2815639876-zapatillas-skate-oversize-clasicas-livianas-_JM?searchVariation=189936656896
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four shapes, three different identifier formats, one search page. The practical consequences are immediate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You cannot pull the ID with one pattern.&lt;/strong&gt; You need the &lt;code&gt;/p/MLA…&lt;/code&gt; path form, the &lt;code&gt;MLA-1234567890&lt;/code&gt; hyphenated form, the &lt;code&gt;wid=&lt;/code&gt; and &lt;code&gt;pdp_filters=item_id:&lt;/code&gt; query forms, and the &lt;code&gt;/up/MLAU…&lt;/code&gt; form — and they do not all mean the same object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot dedupe by URL.&lt;/strong&gt; Two ad impressions of the same product carry different &lt;code&gt;a=&lt;/code&gt; blobs, so string comparison says they are different products. Dedupe on the extracted ID instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot assume the top of the page is organic.&lt;/strong&gt; In my run, 4 of the first 10 rows were paid placements. If you are tracking a competitor's rank, counting ads as rank is the most common way to get a chart that means nothing. I ship &lt;code&gt;isSponsored&lt;/code&gt; as a field for exactly that reason, and &lt;code&gt;includeSponsored: false&lt;/code&gt; drops them at the source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The extraction in my parser is four fallbacks deep, which looks ugly and is the honest shape of the problem:&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;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;idFromUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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="c1"&gt;// /p/MLA… or MLA-123…&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?&amp;amp;#&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;wid=&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;M&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{2}\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// variation picker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromAd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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;/item_id&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;%3A|:&lt;/span&gt;&lt;span class="se"&gt;)(&lt;/span&gt;&lt;span class="sr"&gt;M&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{2}\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ad redirect&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;itemId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;item&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wid&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fromAd&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The honest bug this article found
&lt;/h2&gt;

&lt;p&gt;While gathering the sample for this post I pasted one of those &lt;code&gt;/up/MLAU…&lt;/code&gt; URLs straight into reviews mode. My router does not recognise the shape, so it fell through to the "this must be a listing page" branch, opened it in a browser, found no product cards, and wrote this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"error"&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://www.mercadolibre.com.ar/zapatilla-topper…/up/MLAU3637728744"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no product cards on listing page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"listing"&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 things I will defend about that output, and one I will not.&lt;/p&gt;

&lt;p&gt;I will defend the error row: it is in the dataset, it names the URL, and it is billed at zero. A scraper that silently drops the inputs it could not read is worse than one that admits them, because the dataset still looks finished. And I will defend the routing retry visible in the log — plain HTTP got Mercado Libre's login wall (41 KB of "ingresá a tu cuenta"), the browser took over automatically, no human intervention needed.&lt;/p&gt;

&lt;p&gt;What I will not defend is the router. The fix is to teach &lt;code&gt;idFromUrl&lt;/code&gt; the &lt;code&gt;/up/MLAU…&lt;/code&gt; shape; until it ships, feed reviews mode the &lt;code&gt;productId&lt;/code&gt; / &lt;code&gt;itemId&lt;/code&gt; that the search rows already give you, not the page URL. For the row above that is &lt;code&gt;MLA2796328360&lt;/code&gt;. Search first, then reviews — that chain works today and is what the Actor's own example input does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where units-sold actually lives
&lt;/h2&gt;

&lt;p&gt;Mercado Libre stopped printing "+5 mil vendidos" on most organic cards sometime before I measured this in late September 2026. The number did not go away; it moved into the page's own tracking payload, which is still in the HTML of every search page:&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;function&lt;/span&gt; &lt;span class="nf"&gt;soldByItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{&lt;/span&gt;&lt;span class="sr"&gt;"item_id":"&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;M&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{2}\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;{}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;"sold_quantity":&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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="nc"&gt;Number&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;2&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it from there and you get a figure for every result on the page. Read it only from the visible card text and most rows come back &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat that matters if you plan to do arithmetic: the numbers are Mercado Libre's own rounding, not real sales. My ten rows returned 500, 50, 1000, 100, 5000, 1000, 100, 1000, 500 and 1000. Those are buckets. "Product A sold 1,000 and product B sold 500" means "A is in the 1,000 bucket" — fine for ranking, useless for revenue estimates.&lt;/p&gt;

&lt;p&gt;While we are on untrustworthy integers: &lt;code&gt;totalResults&lt;/code&gt; came back as &lt;strong&gt;9,999&lt;/strong&gt; for &lt;code&gt;zapatillas&lt;/code&gt; in Argentina, and 9,999 again for &lt;code&gt;notebook&lt;/code&gt; in Chile on the same day. That is a ceiling, not a count. Treat any 9,999 as "more than we are being told", the same way you treat a capped page count.&lt;/p&gt;

&lt;p&gt;And prices are never converted. The Chilean row is &lt;code&gt;1009990&lt;/code&gt; with &lt;code&gt;currency: "CLP"&lt;/code&gt;; the Argentine row is &lt;code&gt;49667&lt;/code&gt; with &lt;code&gt;"ARS"&lt;/code&gt;. Mixing marketplaces in one run is supported, and if you average that column without grouping by currency you will produce a very confident, very wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviews: the rating total is not the review total
&lt;/h2&gt;

&lt;p&gt;Reviews come from a JSON endpoint rather than the HTML, which is the cheap half of this Actor — no browser, no proxy traffic. A reviews-mode run on 11 October 2026 (&lt;code&gt;maxReviewsPerProduct: 5&lt;/code&gt;) produced one summary row and five review rows:&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_rating"&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;"MLA2815639876"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ratingAverage"&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.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsWithText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ratingBreakdown"&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;"5"&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;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;75.96&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;"4"&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;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;12.02&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;"3"&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;"count"&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;"percentage"&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.83&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;"2"&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;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"percentage"&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.37&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;"1"&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;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.83&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"picturesCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"videosCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aiSummaryStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"published"&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;&lt;code&gt;ratingCount: 366&lt;/code&gt; but &lt;code&gt;reviewsWithText: 191&lt;/code&gt;. Star ratings without a comment are counted in the average and are not text you can read, so a job sized at "366 reviews" will finish at roughly half that and look like it failed. Size review jobs by &lt;code&gt;reviewsWithText&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The breakdown row is the part I would use even if I never read a single comment: it gives you the true star distribution — 76% five-star here — without paginating anything. Scraping page one of the reviews and counting stars gives you the &lt;em&gt;relevance-sorted&lt;/em&gt; distribution, which is a different and much happier number.&lt;/p&gt;

&lt;p&gt;Three more details from the same run. The endpoint returns &lt;strong&gt;15 reviews per page&lt;/strong&gt;, hard — ask for 100 and you are making seven requests. Every &lt;code&gt;title&lt;/code&gt; field was &lt;code&gt;null&lt;/code&gt;; the text instead carries Mercado Libre's guided-form prefixes, which is free structure if you want per-aspect sentiment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Diseño: hermoso el estilo y el color negro…
 Comodidad: bastante cómodas por dentro, y son ligeras."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And each review names the variant bought — &lt;code&gt;"Color: Negro · Talle: 44 AR"&lt;/code&gt; — plus both a relative date (&lt;code&gt;"Hace 6 meses"&lt;/code&gt;) and a real timestamp (&lt;code&gt;2026-03-17T15:57:45Z&lt;/code&gt;). Use the second one.&lt;/p&gt;

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



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

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;YOUR_APIFY_TOKEN&amp;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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lergassy/mercadolibre-reviews-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&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;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&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;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&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;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;includeSponsored&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itemId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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;soldQuantity&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 lives at &lt;a href="https://apify.com/lergassy/mercadolibre-reviews-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;apify.com/lergassy/mercadolibre-reviews-scraper&lt;/a&gt; — search, product detail, reviews, daily deals and official-store modes in one input, dataset rows priced per result with no per-run fee.&lt;/p&gt;

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

&lt;p&gt;If you are a Mercado Libre &lt;strong&gt;seller&lt;/strong&gt; working with your own listings, orders or stock, use the official API — you have the OAuth credentials and the permissions, and you get write access that no scraper can give you. If you need one price checked once, the product page in a browser is faster than any pipeline. And if you need a guaranteed-complete category, no scraper can promise it: the 9,999 ceiling is real, and the only honest way past it is to slice the query by brand, price band or subcategory until each slice comes back under the cap.&lt;/p&gt;

&lt;p&gt;What scraping is genuinely good for here is the public, cross-seller, cross-country view that the seller API will never hand you: what the competition charges in six currencies, which of their listings are paid placements, and what 191 buyers said about a product you do not sell.&lt;/p&gt;

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