<?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>How to export 50,000 Agoda and Booking.com hotel reviews (and what it costs in 2026)</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Thu, 24 Sep 2026 01:58:49 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/how-to-export-50000-agoda-and-bookingcom-hotel-reviews-and-what-it-costs-in-2026-1fc4</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/how-to-export-50000-agoda-and-bookingcom-hotel-reviews-and-what-it-costs-in-2026-1fc4</guid>
      <description>&lt;p&gt;If you work on hotel analytics, reputation software or an AI travel assistant, sooner or later you need guest reviews in bulk — not five hotels, but whole cities. In Southeast Asia that means Agoda: it's the dominant OTA in the region, and its hotel pages also show the Booking.com review pool.&lt;/p&gt;

&lt;p&gt;This is a short guide to getting that data as clean rows, and what it costs at volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one review row contains
&lt;/h2&gt;

&lt;p&gt;With the &lt;a href="https://apify.com/lergassy/agoda-reviews-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Agoda Reviews Scraper&lt;/a&gt; on Apify, each review becomes one flat row:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rating and review text (positive and negative parts)&lt;/li&gt;
&lt;li&gt;traveler type (solo, couple, family, business)&lt;/li&gt;
&lt;li&gt;room booked and length of stay&lt;/li&gt;
&lt;li&gt;reviewer country and review date&lt;/li&gt;
&lt;li&gt;source: Agoda or Booking.com&lt;/li&gt;
&lt;li&gt;the hotel's reply, if there is one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filters let you keep only certain languages, a minimum rating, or reviews since a given date. You can also pull hotel records (address, rating, review counts) and live room rates for your dates in the same run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open a hotel on agoda.com and copy the URL, e.g. &lt;code&gt;https://www.agoda.com/the-magani-hotel-and-spa/hotel/bali-id.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Paste one or many URLs into the Actor input and choose "Reviews only".&lt;/li&gt;
&lt;li&gt;Run it in the Apify Console, or call it from code:
&lt;/li&gt;
&lt;/ol&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~agoda-reviews-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"startUrls":["https://www.agoda.com/the-magani-hotel-and-spa/hotel/bali-id.html"],"mode":"reviews"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Export as CSV, JSON or Excel, or send rows to Google Sheets, a webhook or your database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a whole city, schedule one run per batch of hotels and let it collect overnight. If you use n8n, there's a ready workflow that appends new reviews to Google Sheets every Monday.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 50,000 reviews cost
&lt;/h2&gt;

&lt;p&gt;Pricing is per review: $0.003, with no per-run fee, and rows that fail are not charged. 50,000 reviews = &lt;strong&gt;$150&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For comparison, the most-used Agoda reviews Actor on Apify Store (as of September 2026) charges $0.006 per review plus $0.02 per run — about $302 for the same 50,000 reviews split over 100 runs. Some smaller Actors ask less per row, so compare fields and reliability, not just price: this one returns both the Agoda and the Booking.com review pools from one page, which saves a second integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from an AI agent
&lt;/h2&gt;

&lt;p&gt;The Actor is available to AI agents through the Apify MCP server and supports x402 agent payments, so an agent can find it, pay per run in USDC and get rows back without an API key setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It reads reviews that Agoda shows publicly; it can't see deleted or hidden ones.&lt;/li&gt;
&lt;li&gt;Very large hotels have thousands of reviews — set a per-hotel limit if you only need recent ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need Trip.com or Google Hotels too, there are sister Actors with the same row format: &lt;a href="https://apify.com/lergassy/tripcom-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Trip.com Scraper&lt;/a&gt; and &lt;a href="https://apify.com/lergassy/google-hotels-scraper?fpr=wmeplu" rel="noopener noreferrer"&gt;Google Hotels Scraper&lt;/a&gt;.&lt;/p&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>webscraping</category>
      <category>travel</category>
      <category>data</category>
      <category>api</category>
    </item>
    <item>
      <title>I cut my scraper's price 6x — the pricing mistake I'd made</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:07:45 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/i-cut-my-scrapers-price-6x-the-pricing-mistake-id-made-4ldf</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/i-cut-my-scrapers-price-6x-the-pricing-mistake-id-made-4ldf</guid>
      <description>&lt;p&gt;I run a small portfolio of scrapers on Apify. One of them pulls flight data out of Google Flights — fares, the full 100–150 itinerary list Google's own endpoint returns, the price calendar for up to a year, Explore destinations, booking options with seller prices.&lt;/p&gt;

&lt;p&gt;I priced it the obvious way: a small fee per route searched, plus a fee per row returned. $0.01 per search, $0.001 per row. Both numbers look tiny.&lt;/p&gt;

&lt;p&gt;Then I ran the real basket. A customer sweeping 1,000 routes and getting ~50 itineraries each — 50,000 rows — pays &lt;strong&gt;$60&lt;/strong&gt;. The same job on the most popular Google Flights Actor in the category costs $30.60. I was the most expensive tool in a category where I had the best review score and almost no users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake is the double charge
&lt;/h2&gt;

&lt;p&gt;A search and the rows it produces are one unit of work. Charging for both means the heavy user — the only user who pays real money — is punished exactly for the behaviour you want from them. Every serious competitor charges once: per row, or per search, never both.&lt;/p&gt;

&lt;p&gt;Here is the same basket across the category, measured on public pricing on 21 September 2026, Free-plan tier, every run and page fee included:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;50,000 flight rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mine, before&lt;/td&gt;
&lt;td&gt;$60.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;competitor A (per row + run fee)&lt;/td&gt;
&lt;td&gt;$50.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;category leader (per page)&lt;/td&gt;
&lt;td&gt;$30.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;competitor B (per row, deep tier discounts)&lt;/td&gt;
&lt;td&gt;$12.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So: the per-search fee is gone. One event, $0.0002 per row — $0.20 per 1,000 flights. No run fee, no page fee, no proxy fee. Error rows and empty results are free, as they always were. The same 50,000-row job is now &lt;strong&gt;$10&lt;/strong&gt;, and the platform's own plan tiers take it to $5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the cost before you cut the price
&lt;/h2&gt;

&lt;p&gt;What made the cut safe was fifteen real runs, read from the API rather than guessed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$0.000011–0.000034 per row&lt;/strong&gt;, including residential proxy.&lt;/li&gt;
&lt;li&gt;Proxy traffic: &lt;strong&gt;0.045 MB per 100 rows&lt;/strong&gt;. The Actor talks to Google's JSON endpoint, so there is no browser and no page weight.&lt;/li&gt;
&lt;li&gt;Margin at the deepest discount tier: &lt;strong&gt;6x&lt;/strong&gt;. At the top tier, 13x.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you price before you measure, you are guessing in both directions at once: you either leave money on the table or sell below cost to the one customer who finally scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I'd tell anyone pricing a pay-per-event scraper
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Price the basket, not the event.&lt;/strong&gt; Write down what your biggest realistic customer does in a month, then compute that single number for you and for every competitor. The per-event price tells you nothing — $0.03 per page can be cheaper than $0.001 per row, and usually is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check who pays the platform bill.&lt;/strong&gt; Some Actors advertise $0.05 per 1,000 rows and pass compute and proxy usage to the buyer's own account on top. That is not a cheaper tool, it is a different invoice. Chasing that headline number is how you end up pricing below your own costs to beat a competitor who has no costs.&lt;/p&gt;

&lt;p&gt;The third thing, which I'm testing now: the pricing page is a marketing surface. Three of the four most-used Actors in this category put the price in the title — &lt;code&gt;$1/1K&lt;/code&gt;, &lt;code&gt;~$0.05/1K&lt;/code&gt;, &lt;code&gt;$3.00/1k&lt;/code&gt;. Mine listed features. That is a free position in a buyer's mind and I was not using it.&lt;/p&gt;

&lt;p&gt;The Actor, if you want to look at the shape of it: &lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;Google Flights Scraper&lt;/a&gt;. Feedback and feature requests genuinely decide what I build next — the last four features all came from one.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>pricing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Scraping Meta's Ad Library Without a Browser: the 200 OK That Means Blocked</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 18 Sep 2026 21:43:07 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/scraping-metas-ad-library-without-a-browser-the-200-ok-that-means-blocked-18og</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/scraping-metas-ad-library-without-a-browser-the-200-ok-that-means-blocked-18og</guid>
      <description>&lt;p&gt;Meta publishes every ad running on Facebook and Instagram in its &lt;a href="https://www.facebook.com/ads/library/" rel="noopener noreferrer"&gt;Ad Library&lt;/a&gt;, and offers an official API only for political ads, behind an identity review. So everyone else scrapes the page.&lt;/p&gt;

&lt;p&gt;Most of the tools that do it drive a whole headless browser, and you can see the cost of that decision from the outside: on Apify, every Actor in this niche that runs with 4 GB of memory sits at a couple of hundred monthly users, while the ones above a thousand run in &lt;strong&gt;128 MB to 1 GB&lt;/strong&gt; — two of the three biggest in 512 MB and 128 MB. A browser does not fit in 128 MB. The leaders are not rendering anything.&lt;/p&gt;

&lt;p&gt;Here is what they are doing instead, and the four traps between you and the same result. All of it measured on 19 September 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge is on the page, not on the API
&lt;/h2&gt;

&lt;p&gt;Request &lt;code&gt;facebook.com/ads/library?q=nike&lt;/code&gt; from a server and you get a 481-byte document that is not the Ad Library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;Ad Library&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;executeChallenge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/__rd_verif...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JavaScript challenge. That single response is why so many implementations reach for a browser — and it is a wrong turn, because the challenge guards the HTML page and nothing else.&lt;/p&gt;

&lt;p&gt;The page's own data comes from &lt;code&gt;facebook.com/api/graphql/&lt;/code&gt;, which has no challenge. It needs one thing: an &lt;code&gt;LSD&lt;/code&gt; token, which any ordinary &lt;code&gt;facebook.com&lt;/code&gt; response carries in its inline config.&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;home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.facebook.com/&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;lsd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"LSD",&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;"token":"&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"__spin_r":&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;/&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then post the Ad Library's own query with it:&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;body&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;av&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;__user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;__a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;__req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dpr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;__rev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lsd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fb_api_caller_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RelayModern&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fb_api_req_friendly_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AdLibrarySearchPaginationQuery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;server_timestamps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;24394279933540792&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;activeStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;adType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;countries&lt;/span&gt;&lt;span class="p"&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;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nike&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cursor&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="na"&gt;searchType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KEYWORD_UNORDERED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="c1"&gt;// …the rest of the page's own variables&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.facebook.com/api/graphql/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;content-type&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;application/x-www-form-urlencoded&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;x-fb-lsd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lsd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.facebook.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;referer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.facebook.com/ads/library/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&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;Thirty ads, &lt;code&gt;page_info.end_cursor&lt;/code&gt; for the next page, &lt;strong&gt;8.2 KB per ad&lt;/strong&gt;, 3.5 seconds per request. No browser, no rendering, no 4 GB.&lt;/p&gt;

&lt;p&gt;Use an HTTP client that presents a real TLS fingerprint — &lt;a href="https://github.com/apify/impit" rel="noopener noreferrer"&gt;impit&lt;/a&gt;, curl-impersonate, tls-client. Plain &lt;code&gt;fetch&lt;/code&gt; has its own handshake and gets a different answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the empty 200
&lt;/h2&gt;

&lt;p&gt;This is the one worth the whole article.&lt;/p&gt;

&lt;p&gt;From a datacentre address, that GraphQL call does not fail. It returns:&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;HTTP 200
Content-Length: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error, no status code to branch on, no message. Just nothing. Measured side by side on the same query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit address&lt;/th&gt;
&lt;th&gt;Home page&lt;/th&gt;
&lt;th&gt;GraphQL&lt;/th&gt;
&lt;th&gt;Ads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server, direct&lt;/td&gt;
&lt;td&gt;200, 455 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, empty&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datacentre proxy, US&lt;/td&gt;
&lt;td&gt;200, 455 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200, empty&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Residential proxy, US&lt;/td&gt;
&lt;td&gt;200, 455 KB&lt;/td&gt;
&lt;td&gt;200, 245 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The home page loads fine from every address, which makes it worse: your session looks healthy right up to the query that matters.&lt;/p&gt;

&lt;p&gt;If your code treats an empty parse as "no ads matched this search", you will ship something that quietly returns nothing for every search and reports success while doing it. That is not hypothetical — "returning no ads results" and "actor gives incomplete data" are recurring complaints on the popular scrapers in this niche, and this is what it looks like from the inside.&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Meta answered 200 with an empty body — this exit is not being served&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;An empty body from an endpoint that always returns JSON is a block. Say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: two filters Meta accepts and ignores
&lt;/h2&gt;

&lt;p&gt;The Ad Library query takes a date range and a media type. It does not honour either one.&lt;/p&gt;

&lt;p&gt;Measured on &lt;code&gt;meal kit&lt;/code&gt;, United States, asking for ads that started &lt;strong&gt;after 1 September 2026&lt;/strong&gt;: of the first 30 ads returned, &lt;strong&gt;28 started earlier&lt;/strong&gt; — one of them in March 2025. Asking for &lt;strong&gt;videos only&lt;/strong&gt; on &lt;code&gt;skincare&lt;/code&gt;: of 30 ads, &lt;strong&gt;14 had no video at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;No error, no warning. The parameters are accepted and the unfiltered result comes back.&lt;/p&gt;

&lt;p&gt;Every scraper in this niche carries the matching complaint — "Filter on Active ads not working", "Mediatype prefilter issue (video)" — and they are all downstream of this. Re-apply the filters on your side:&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;passes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mediaType&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;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_date&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_date&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;startDate&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;startDate&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;hasVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ad&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;Then report the ratio, because it is large and your user is paying for the traffic either way. One run asking for 20 date-filtered ads read &lt;strong&gt;181 ads from Meta and discarded 161&lt;/strong&gt; of them. A user who sees &lt;code&gt;scanned: 181, filteredOut: 161, delivered: 20&lt;/code&gt; understands the run. A user who just sees 20 rows after seven page fetches suspects the scraper.&lt;/p&gt;

&lt;p&gt;Cap the paging while you are there. A filter that matches nothing will otherwise walk the whole result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the media is inside the cards
&lt;/h2&gt;

&lt;p&gt;A carousel or catalogue ad (&lt;code&gt;DPA&lt;/code&gt;, &lt;code&gt;DCO&lt;/code&gt;) has empty &lt;code&gt;snapshot.images&lt;/code&gt; and &lt;code&gt;snapshot.videos&lt;/code&gt;. The media lives one level down:&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;cards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cards&lt;/span&gt; &lt;span class="o"&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;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extra_videos&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;video_hd_url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;video_sd_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;video_hd_url&lt;/span&gt; &lt;span class="o"&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;video_sd_url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read only the top level and a video ad arrives with no video URL — which then makes your own media filter look broken when it is working correctly. I shipped exactly that bug and found it by testing the promise, not the code.&lt;/p&gt;

&lt;p&gt;While you are in &lt;code&gt;snapshot&lt;/code&gt;: catalogue ads keep their template placeholders. A live Nike ad's headline is literally &lt;code&gt;{{product.name}}&lt;/code&gt;, because the real product is substituted at delivery and the Ad Library stores the template. Pass it through, but flag it — a user counting headlines needs to know those are not copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: spend and reach do not exist for commercial ads
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;spend&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;reach_estimate&lt;/code&gt; and &lt;code&gt;impressions_with_index&lt;/code&gt; are null on every ordinary ad. They are not missing because of your request. Meta publishes them &lt;strong&gt;only&lt;/strong&gt; for ads it has classified as political or social-issue, which is also the only shelf its official API covers.&lt;/p&gt;

&lt;p&gt;Ask for that shelf with &lt;code&gt;adType: 'POLITICAL_AND_ISSUE_ADS'&lt;/code&gt; and the same fields fill in:&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;"page_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;"Meta"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;$1M"&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;"impressions_with_index"&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;"impressions_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;"&amp;gt;1M"&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;"reach_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;1M"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"categories"&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;"POLITICAL"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ranges, not numbers — that is Meta's own granularity. Which means any tool showing you an exact spend figure for a commercial ad is showing you something it made up. One review on a popular scraper reports an ad spend "around 100 billion"; that is what an invented number looks like when it reaches a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the shape of this tells you
&lt;/h2&gt;

&lt;p&gt;Four of these five problems return &lt;strong&gt;HTTP 200 and plausible-looking data&lt;/strong&gt;. The block, the ignored filters, the empty media array — none of them raise anything. Only the missing spend column is visibly absent, and that one is not a bug at all.&lt;/p&gt;

&lt;p&gt;That is the actual lesson for scraping a large platform: the failures that cost you users do not throw. Test the promise — ask for ads after a date and check the dates that came back, ask for videos and count the video URLs, run the same query from two different networks and compare. The code passing its own tests tells you nothing about whether the platform answered you honestly.&lt;/p&gt;




&lt;p&gt;The scraper these came out of is &lt;a href="https://apify.com/lergassy/meta-ad-library-scraper" rel="noopener noreferrer"&gt;Meta Ad Library Scraper&lt;/a&gt;: ads from Facebook and Instagram as flat rows, filters re-checked on delivery, and a run summary that says how many ads were read, how many were discarded and why it stopped.&lt;/p&gt;

</description>
      <category>python</category>
      <category>javascript</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your YouTube Transcript Scraper Says 'No Captions Found' on a Video That Has Captions</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 18 Sep 2026 15:58:02 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/why-your-youtube-transcript-scraper-says-no-captions-found-on-a-video-that-has-captions-2ea9</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/why-your-youtube-transcript-scraper-says-no-captions-found-on-a-video-that-has-captions-2ea9</guid>
      <description>&lt;p&gt;Spend an hour in the issue trackers of the popular YouTube transcript tools and one complaint drowns out the rest. Someone pastes a URL, gets &lt;code&gt;No caption was found!&lt;/code&gt;, opens the video in a browser, turns captions on, and they are right there.&lt;/p&gt;

&lt;p&gt;I went through 30 issues on the most-used transcript Actor on Apify — it carries a 3.7 rating across 49 reviews, which is what that class of bug does to a listing — and then hit every one of them myself while building a competitor. Four distinct causes, none of them obvious, all of them cheap to fix once you know what you are looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The language code is not the language code
&lt;/h2&gt;

&lt;p&gt;This is the big one.&lt;/p&gt;

&lt;p&gt;YouTube's caption map does not use bare language codes. A video with English subtitles written by its uploader has &lt;code&gt;en&lt;/code&gt;. A video with English subtitles contributed by a viewer, or auto-translated, has something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en-eEY6OEpapPo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That suffix is a track identifier, not a locale. Despacito — 9.1 billion views, captions in dozens of languages — serves its English track under exactly that key.&lt;/p&gt;

&lt;p&gt;So this, which is what most implementations do:&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;track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtitles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# language = "en"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returns &lt;code&gt;None&lt;/code&gt; on a video that plainly has English captions, and the user gets told there are none.&lt;/p&gt;

&lt;p&gt;Match by prefix, and prefer the plainest label when several qualify:&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;find_track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wanted&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;keys&lt;/span&gt; &lt;span class="o"&gt;=&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&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;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;exact&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prefixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;prefixed&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;fmt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fmt&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;ext&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;json3&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;fmt&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;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorting by &lt;code&gt;(len(k), k)&lt;/code&gt; puts &lt;code&gt;en&lt;/code&gt; ahead of &lt;code&gt;en-eEY6OEpapPo&lt;/code&gt;, so a hand-written track wins over a contributed one when both exist. Report the key you actually used back to the caller — the difference between a human translation and a machine one matters to whoever is reading the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Just use the video's own language" is not reproducible
&lt;/h2&gt;

&lt;p&gt;The obvious fix for "user asked for English, video is Spanish" is a fallback: take the language the video says it is in. &lt;code&gt;yt-dlp&lt;/code&gt; exposes it as &lt;code&gt;info["language"]&lt;/code&gt;, so:&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;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;info&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;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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;everything_else&lt;/span&gt;   &lt;span class="c1"&gt;# looks right
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped that, ran it twice against the same video, and got two different transcripts. First run: &lt;code&gt;es&lt;/code&gt;, the Spanish original. Second run, same code, same video, minutes apart: the field came back empty and the fallback moved on to English.&lt;/p&gt;

&lt;p&gt;The field is derived from whatever player response the extraction happened to get. It varies with the client YouTube answers, and therefore with your proxy exit, the time of day, and nothing you control. It is a hint, not a fact.&lt;/p&gt;

&lt;p&gt;If your fallback is going to be documented — and it should be, because the caller is paying for rows — it has to be reproducible. Mine ended up as a fixed order with the unreliable field demoted:&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;order&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;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="n"&gt;info&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;language&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;manual_codes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;auto_codes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;English first because that is what most callers want and it is a stable choice; the video's declared language second, where it helps when present and costs nothing when absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An alphabetical fallback returns German
&lt;/h2&gt;

&lt;p&gt;Before I settled on that order, the fallback was "any manual track, shortest label first" — which sorts alphabetically among equals.&lt;/p&gt;

&lt;p&gt;Run it on &lt;code&gt;jNQXAC9IVRw&lt;/code&gt;, the first video ever uploaded to YouTube. It has community subtitles in a long list of languages. The transcript that came back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Also hier sind wir vor den Elefanten. Das Coole an den Typen ist dass sie sehr, sehr, sehr, lange Rüssel haben.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Correct German. Completely unexpected output for an English video, and the user cannot predict it, because the reason is that &lt;code&gt;de&lt;/code&gt; sorts before &lt;code&gt;en&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Any implicit ordering becomes a product decision the moment a fallback exists. Sorting is implicit ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Markup survives into your text
&lt;/h2&gt;

&lt;p&gt;Caption tracks carry styling. Community-written ones especially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;i&amp;gt;&lt;/span&gt;Ooh, oh, no&lt;span class="nt"&gt;&amp;lt;/i&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;font&lt;/span&gt; &lt;span class="na"&gt;color=&lt;/span&gt;&lt;span class="s"&gt;"#E5E5E5"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;hello&lt;span class="nt"&gt;&amp;lt;/font&amp;gt;&lt;/span&gt;
Tom &lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt; Jerry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a subtitle renderer that is formatting. In a transcript headed for summarisation, keyword search or a RAG index, it is noise that costs tokens and pollutes matching. Strip tags, decode entities.&lt;/p&gt;

&lt;p&gt;One trap in doing it. The obvious pattern:&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;TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;[^&amp;gt;]{1,120}&amp;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;eats arithmetic. A transcript containing &lt;code&gt;a &amp;lt; b and c &amp;gt; d&lt;/code&gt; comes out as &lt;code&gt;a  d&lt;/code&gt;, because everything between the first &lt;code&gt;&amp;lt;&lt;/code&gt; and the next &lt;code&gt;&amp;gt;&lt;/code&gt; looked like a tag. Require a tag to open with a letter or a slash:&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;TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/?[a-zA-Z][^&amp;gt;]{0,120}&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TAG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unescape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unescape before stripping, or &lt;code&gt;&amp;amp;lt;i&amp;amp;gt;&lt;/code&gt; survives as literal text after the real tags are gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifth one, which is not about captions at all
&lt;/h2&gt;

&lt;p&gt;Half the "stopped working" reports in that tracker are not caption bugs. They are one unhandled string.&lt;/p&gt;

&lt;p&gt;Fetch a batch of videos concurrently and YouTube will drop connections. Most transient-error lists cover the usual suspects — &lt;code&gt;SSL&lt;/code&gt;, &lt;code&gt;EOF&lt;/code&gt;, &lt;code&gt;Connection reset&lt;/code&gt;, &lt;code&gt;timed out&lt;/code&gt; — and miss this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote end closed connection without response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It arrived as an ordinary exception, fell through the retry branch, and turned into a failed row. It cost me one video out of six on two separate batches before I read the message properly; with the string added, the same batch came back six for six.&lt;/p&gt;

&lt;p&gt;A retry list is a list of literals someone wrote down once. It is worth re-reading it against what your logs actually contain, rather than what you assumed they would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test before you ship
&lt;/h2&gt;

&lt;p&gt;None of these show up on a happy-path video. The set that finds them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A video whose only captions are contributed or auto-translated, so the key carries a suffix.&lt;/li&gt;
&lt;li&gt;A video with captions in many languages and none declared — tests your fallback's determinism.&lt;/li&gt;
&lt;li&gt;A non-English video with no English track at all.&lt;/li&gt;
&lt;li&gt;A video with styled captions, and one whose transcript contains &lt;code&gt;&amp;lt;&lt;/code&gt; or &lt;code&gt;&amp;gt;&lt;/code&gt; as text.&lt;/li&gt;
&lt;li&gt;The same video twice, minutes apart, compared byte for byte. If two runs disagree, something in your path is reading a field that is not stable.&lt;/li&gt;
&lt;li&gt;A batch large enough that the host starts dropping connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are the ones that get skipped, and they are the two that produce the reviews nobody wants.&lt;/p&gt;




&lt;p&gt;The scraper these came out of is &lt;a href="https://apify.com/lergassy/youtube-transcript-scraper" rel="noopener noreferrer"&gt;YouTube Transcript Scraper&lt;/a&gt; — transcripts with timecodes from videos, playlists and whole channels, SRT and WebVTT, and chunks that keep their timecodes for RAG. A video with no captions returns a free error row that lists the languages it does have, which is the honest answer to the complaint this whole article is about.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>Scraping Meta Threads Without a Login — What the Search Page Actually Returns</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Wed, 16 Sep 2026 23:38:46 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/scraping-meta-threads-without-a-login-what-the-search-page-actually-returns-2ngk</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/scraping-meta-threads-without-a-login-what-the-search-page-actually-returns-2ngk</guid>
      <description>&lt;p&gt;Threads has no public API for other people's content. Meta ships one for your own account, and that is it. Everything else — brand monitoring, competitor tracking, research — has to come from the pages Threads serves to a visitor who is not logged in.&lt;/p&gt;

&lt;p&gt;Those pages are more generous than people assume, and more misleading in one specific way. Both parts matter if you are building on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a logged-out visitor actually gets
&lt;/h2&gt;

&lt;p&gt;Open a profile without an account and Threads renders the page server-side, with the data embedded as Relay payloads inside the HTML. No browser automation is needed to read them: one HTTP request with a believable TLS fingerprint gets you the same JSON the page uses.&lt;/p&gt;

&lt;p&gt;From those payloads you can take:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An account's posts&lt;/strong&gt;, paged through the same GraphQL endpoint the site calls when you scroll. Not the first 25 — a profile with 1,400 posts pages all the way down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replies and reposts&lt;/strong&gt;, each with the post being answered, so a conversation can be rebuilt from flat rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single post with its reply tree&lt;/strong&gt;, parent links included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profiles&lt;/strong&gt;: follower count, bio, bio links, external website, verification, linked Instagram, and any e-mail or phone the owner put in the bio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword and hashtag search&lt;/strong&gt;, from the top, recent and tag result pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every post carries text, timestamp, author, likes, replies, reposts, quotes, media URLs, links, mentions and hashtags. That is a complete social dataset, from a site with no public API, without a single browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that quietly breaks analyses
&lt;/h2&gt;

&lt;p&gt;Threads' logged-out search pages return posts that do not contain your query at all.&lt;/p&gt;

&lt;p&gt;Not a handful of edge cases — measured on 16 September 2026 for the query &lt;code&gt;apify&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result page&lt;/th&gt;
&lt;th&gt;Results&lt;/th&gt;
&lt;th&gt;Actually containing the word&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recent&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashtag page&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hashtag page is mostly noise. And an earlier run of the same query on the recent page returned five posts, all published in the same second, in five different languages, none mentioning the query — a global firehose, served as if it were a search result.&lt;/p&gt;

&lt;p&gt;If you are counting brand mentions, that is not a rounding error. It is a number that is wrong by a factor of two or three, in the direction that makes your dashboard look busier.&lt;/p&gt;

&lt;p&gt;The fix is not clever, it just has to be there: after fetching, keep only posts whose text or hashtags actually contain the query — the whole phrase, or every word of it for multi-word queries. Anything that does not pass is dropped before it is counted, stored, or billed.&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;is_mention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;text&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;hashtags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q&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="k"&gt;if&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;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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hay&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why view counts are null, and why that is the honest answer
&lt;/h2&gt;

&lt;p&gt;Threads shows a view count on a handful of posts to logged-out visitors, and hides it on the rest. Some scrapers fill the gap with a zero. That single decision quietly destroys any filter built on it: ask for "posts with at least 1,000 views" and you get an empty dataset, because almost every row was assigned a zero.&lt;/p&gt;

&lt;p&gt;Null is the correct value for "the platform did not tell us". Filter on likes or replies, which are always there.&lt;/p&gt;

&lt;p&gt;The same rule applies to search depth. Threads shows a logged-out visitor roughly one page per query and result type, with no cursor. You can widen the net by asking in more ways — singular and plural, with and without the hash, top and recent — and deduplicating by post id, which in practice triples the unique posts you get. What you cannot do is promise thousands of results per keyword, and anyone who does is either logged in or counting duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scraper on a schedule is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Here is the pattern almost everyone builds first: point a scraper at an account, run it hourly, store everything, deduplicate later.&lt;/p&gt;

&lt;p&gt;Run the numbers. Twenty-five posts per check, twenty-four checks a day, thirty days: 18,000 rows a month per account, of which maybe 60 are new. You paid for all 18,000, and you wrote the deduplication yourself.&lt;/p&gt;

&lt;p&gt;The alternative is to make "what changed" the product rather than a post-processing step. A named watch keeps the set of post ids it has already delivered in a key-value store. Every run fetches the top of the feed, subtracts what it has seen, delivers the difference, and writes the union back:&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;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openKeyValueStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`threads-watch-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seen&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seen&lt;/span&gt;&lt;span class="dl"&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;seen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve lines, and the economics invert: you pay for the 60 new posts instead of the 18,000 repeats, and an hour when nothing was posted costs nothing at all. The same trick works for prices, job listings, and anything else you check more often than it changes.&lt;/p&gt;

&lt;p&gt;Two details make it survive contact with reality. First, the very first run has nothing to compare against — deliver what is there and say so on the row, rather than returning an empty dataset that reads as a bug. Second, keep filtered-out posts in the seen set. A post you decided to ignore today must not come back tomorrow as breaking news.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legal and practical boundaries
&lt;/h2&gt;

&lt;p&gt;Public pages, read the way a visitor reads them, with no login and no account of yours at risk. Public data collection is generally lawful in the EU and the US, but personal data is regulated wherever it lands, and bios contain plenty of it. That is a question for your lawyer, not for your scraper.&lt;/p&gt;

&lt;p&gt;Practically: no login means no private accounts, no follower lists, and no direct messages. It also means nothing you run can get an account of yours restricted, which is the trade most teams would take anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not build it
&lt;/h2&gt;

&lt;p&gt;Both pieces exist as ready actors on the Apify platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/lergassy/threads-scraper" rel="noopener noreferrer"&gt;&lt;strong&gt;Threads Scraper&lt;/strong&gt;&lt;/a&gt; — search, posts, replies, whole threads, profiles and account discovery, with the query-match filter and the null-instead-of-zero rule described above. $2 per 1,000 posts, errors free, no start fee.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/lergassy/threads-monitor" rel="noopener noreferrer"&gt;&lt;strong&gt;Threads Monitor&lt;/strong&gt;&lt;/a&gt; — the delta mode as a product: name a watch, schedule it, receive only what appeared since the last check. A run that finds nothing new is free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both read the same public pages this article describes. The measurements above come from real runs, not from the documentation.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>I made my Apify Actor an AI agent tool, then read every byte it sent back</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:05:36 +0000</pubDate>
      <link>https://dev.to/apify/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back-1nbj</link>
      <guid>https://dev.to/apify/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back-1nbj</guid>
      <description>&lt;p&gt;I have about thirty Actors on the Apify Store. Making one of them available to an AI agent through the Apify MCP server took me two minutes: add &lt;code&gt;?actors=lergassy/jobs-api&lt;/code&gt; to the server URL and the Actor shows up as a tool. That part is a footnote.&lt;/p&gt;

&lt;p&gt;The useful part was what came back. I spent an afternoon calling my own Actor the way an agent calls it — raw JSON-RPC over the wire, no client in between — and logging every response. Four things surprised me, and three of them changed how I write input schemas.&lt;/p&gt;

&lt;p&gt;The Actor here is &lt;a href="https://apify.com/lergassy/jobs-api" rel="noopener noreferrer"&gt;Jobs API&lt;/a&gt;: job listings from Indeed, LinkedIn and company career boards in one schema. Nothing about what follows is specific to jobs, though. If your Actor has more than three inputs, the same things will happen to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Talking to the server without a client
&lt;/h2&gt;

&lt;p&gt;Every walkthrough I found used Claude Desktop or Cursor. I wanted the traffic, not a chat transcript, so I used &lt;code&gt;curl&lt;/code&gt;. The Apify MCP server speaks streamable HTTP: you POST JSON-RPC, you get back server-sent events.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;HERE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;
&lt;span class="n"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HERE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mcp.apify.com/?actors=lergassy/jobs-api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cmd&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;curl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-time&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;300&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HERE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type: application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept: application/json, text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cmd&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;-H&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mcp-Session-Id: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;cmd&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;-d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session&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;jsonrpc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;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;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initialize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protocolVersion&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;2025-06-18&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;capabilities&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;clientInfo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl-client&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;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}})&lt;/span&gt;
    &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HERE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-session-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;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;jsonrpc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notifications/initialized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right or nothing works. The &lt;code&gt;Accept&lt;/code&gt; header has to name both &lt;code&gt;application/json&lt;/code&gt; and &lt;code&gt;text/event-stream&lt;/code&gt; — the server rejects the request otherwise. And the session id comes back in a response header, not in the body, which is why I dump headers to a file and read them back.&lt;/p&gt;

&lt;p&gt;With a session open, &lt;code&gt;tools/list&lt;/code&gt; shows what the agent gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 tools
- get-actor-run
- get-dataset-items
- get-key-value-store-record
- abort-actor-run
- lergassy--jobs-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My Actor is one tool. The other four are the plumbing around it: start a run, poll it, read the dataset, kill it. That shape matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema the agent reads is not the schema I wrote
&lt;/h2&gt;

&lt;p&gt;My &lt;code&gt;input_schema.json&lt;/code&gt; has 23 properties. The tool definition the agent receives has 24. The server adds one of its own, and it is the single most important field in the whole exchange — I will come back to it.&lt;/p&gt;

&lt;p&gt;The rest is my schema, rewritten. Here is one property as I wrote it, and as the agent sees it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"🔎 Job titles or keywords"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One search per line: &amp;lt;code&amp;gt;python developer&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;registered nurse&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;marketing manager&amp;lt;/code&amp;gt;. Boolean syntax the boards support works too (&amp;lt;code&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;data engineer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; -senior&amp;lt;/code&amp;gt; on Indeed).&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Example values: [&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;python developer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prefill"&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;"python developer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"examples"&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;"python developer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three observations, all of which cost me something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My emoji and my HTML went straight through.&lt;/strong&gt; The &lt;code&gt;🔎&lt;/code&gt; in the title and the &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; tags in the description were written for the Apify Console input form, where they render. In a tool definition they are tokens an agent pays for and markup it has to ignore. Nobody strips them. Across the whole tool my schema is 9,135 characters, and a slice of that is decoration for a form the agent will never see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;prefill&lt;/code&gt; gets promoted into the description.&lt;/strong&gt; The server appends &lt;code&gt;Example values: ["python developer"]&lt;/code&gt; to the text. That is a genuinely good move — it converts a Console nicety into an instruction — but it means the &lt;code&gt;prefill&lt;/code&gt; field is now documentation. I had a couple of Actors where prefill was a throwaway placeholder. Those placeholders are now the example the model imitates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;required&lt;/code&gt; is empty.&lt;/strong&gt; That is my fault, not the server's. My schema requires nothing, so the tool definition tells the agent that a call with zero arguments is valid. It is not: the Actor has no useful default search. An agent that believes the schema will produce an empty run, and the run will succeed while doing so.&lt;/p&gt;

&lt;p&gt;Which is exactly what happened next.&lt;/p&gt;

&lt;h2&gt;
  
  
  A green run that returned nothing
&lt;/h2&gt;

&lt;p&gt;My first real call, with arguments I would have called obviously correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"python developer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Berlin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sources"&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;"indeed"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxJobsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUCCEEDED in 3.297s. Dataset item count reads 0 — counts can lag right after a run
finishes. Key-value store has 1 key.
Fetch get-dataset-items with datasetId=paykg466SehjPTgFI and limit (for example 20)
before concluding the run produced no output.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I fetched them. &lt;code&gt;"items": [], "itemCount": 0&lt;/code&gt;. Zero jobs, status SUCCEEDED, exit code 0.&lt;/p&gt;

&lt;p&gt;The cause is embarrassing once you see it. &lt;code&gt;country&lt;/code&gt; defaults to &lt;code&gt;us&lt;/code&gt;, and I did not pass it. So the Actor searched &lt;strong&gt;Indeed US&lt;/strong&gt; for jobs in &lt;strong&gt;Berlin&lt;/strong&gt; and correctly found none. Same call with &lt;code&gt;"country": "de"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUCCEEDED in 7.874s. 10 items; 44 fields available.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was broken. The schema was: two fields have to agree with each other, and no part of the tool definition says so. A human filling in the Console form sees a country dropdown sitting next to a location box and picks the matching one. An agent reads two independent properties, one with a default, and has no reason to touch the one it did not need.&lt;/p&gt;

&lt;p&gt;This is the difference between an Actor that works and an Actor that is agent-usable, and it is not a code change. The fix goes in the prose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;location&lt;/code&gt; now says, in its description, that it must be consistent with &lt;code&gt;country&lt;/code&gt;, and names what happens when it is not — an empty result, not an error.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;country&lt;/code&gt; no longer silently defaults to &lt;code&gt;us&lt;/code&gt; in the description text. The default stays, because breaking existing users over this would be worse, but the description states it in the first sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing I would do differently from the start: write field descriptions for a reader who cannot see the other fields. A form is a layout. A tool definition is a flat list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 45-second ceiling nobody mentions
&lt;/h2&gt;

&lt;p&gt;Here is the property the server adds to every Actor tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"waitSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Max seconds (0–45, default 30) to cap the wait for the Actor run to reach terminal state..."&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;Forty-five seconds, hard maximum. A tool call cannot block longer than that. My small Berlin run finished in 7.9 seconds and fit comfortably. A realistic one does not. Three keywords, two sources, 100 jobs per query, full descriptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RUNNING for 5s. In progress.
Use get-actor-run with runId=jXPenvUnx5V3d5oHg and waitSecs=30 to poll for completion.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, polling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RUNNING for 43s. In progress. 296 results so far.
RUNNING for 74s. In progress. 485 results so far.
SUCCEEDED in 77.813s. 485 items; 51 fields available.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy-eight seconds. Three round trips. Every scraper I own that does anything substantial runs longer than 45 seconds, which means the normal path for an agent is not &lt;em&gt;call tool, get data&lt;/em&gt; — it is &lt;em&gt;start, poll, poll, fetch&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The server handles this better than I expected. Each response ends with a &lt;code&gt;nextStep&lt;/code&gt; line naming the exact tool and the exact identifier to use next. That is why &lt;code&gt;get-actor-run&lt;/code&gt; and &lt;code&gt;get-dataset-items&lt;/code&gt; are in the tool list: the polling loop is not something the agent has to invent.&lt;/p&gt;

&lt;p&gt;What it means for me as an Actor author:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emit partial results as you go.&lt;/strong&gt; The &lt;code&gt;296 results so far&lt;/code&gt; line is only there because my Actor pushes to the dataset during the run instead of at the end. An Actor that buffers everything and writes once at the finish shows &lt;code&gt;0 results so far&lt;/code&gt; for 78 seconds, and an agent may well give up on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a cheap mode.&lt;/strong&gt; A call that can finish inside 45 seconds — smaller caps, descriptions off — is worth having, because single-shot beats a poll loop every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail loudly, not emptily.&lt;/strong&gt; An agent reads SUCCEEDED plus zero items as an answer about the world: there are no Python jobs in Berlin. That is worse than an error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  116,483 characters versus 2,579
&lt;/h2&gt;

&lt;p&gt;The last measurement is the one I would put in front of anyone pricing an agent workflow.&lt;/p&gt;

&lt;p&gt;I fetched my 10 Berlin jobs with no field selection. The response was &lt;strong&gt;116,483 characters&lt;/strong&gt; — roughly 29,000 tokens, for ten job listings, because the dataset has 44 fields per row and one of them is a full job description.&lt;/p&gt;

&lt;p&gt;Then the same ten rows with the fields an agent actually needs to answer &lt;em&gt;what Python jobs are open in Berlin&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="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;jsonrpc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools/call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datasetId&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;X7bLTUTlRMIyunaI5&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&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;title,company,location,salaryMin,salaryMax,applyUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}},&lt;/span&gt; &lt;span class="n"&gt;sid&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;2,579 characters.&lt;/strong&gt; Forty-five times smaller, same answer.&lt;/p&gt;

&lt;p&gt;Note the type: &lt;code&gt;fields&lt;/code&gt; is a comma-separated &lt;strong&gt;string&lt;/strong&gt;, not an array. I passed a list first, the way my own schema takes arrays, and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP error -32602: Invalid arguments for tool "get-dataset-items".
Validation errors: /fields: must be string.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean, recoverable error — the agent is told the type and which path failed. That is the standard my own error messages should meet and mostly do not.&lt;/p&gt;

&lt;p&gt;The Actor-author lesson is about field order, not about &lt;code&gt;fields&lt;/code&gt;. The server lists available fields back to the agent in the order the dataset defines them, and a model asked to choose will lean on the first ones it reads. My wide rows now start with title, company, location, salary and apply link, and the bulky text sits at the end. It costs nothing and it moves the default behaviour in the right direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;Four edits, all in the input schema, none in the scraping code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;location&lt;/code&gt; now states the cross-field constraint in words: it has to agree with &lt;code&gt;country&lt;/code&gt;, and a mismatch returns zero rows rather than an error. The agent has no layout to infer that from.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;country&lt;/code&gt; leads with the fact that it defaults to &lt;code&gt;us&lt;/code&gt;, instead of burying it under a list of sixty country codes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;keywords&lt;/code&gt; says it is required unless career-site boards or start URLs are filled in. I did not add it to &lt;code&gt;required&lt;/code&gt;, because runs driven by start URLs alone are legitimate and marking it required would break them — but "call with no arguments" no longer reads as sensible.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prefill&lt;/code&gt; values are real, correct examples everywhere, now that I know the server promotes them into the description the model reads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One thing I did not have to change, and only noticed because of this exercise: the Actor pushes rows to the dataset as it goes rather than at the end. That is why the polling responses said &lt;code&gt;296 results so far&lt;/code&gt; instead of &lt;code&gt;0&lt;/code&gt;. An Actor that buffers everything and writes once at the finish looks dead for seventy-eight seconds, and an agent has no way to tell that apart from a stuck run. If yours buffers, that is the highest-value fix on this list.&lt;/p&gt;

&lt;p&gt;None of this makes an Actor smarter. It makes it legible to a caller that can only read the schema, cannot see the Console, will not notice that two dropdowns belong together, and pays by the token for everything you hand back.&lt;/p&gt;

&lt;p&gt;If you want to look at the tool your own Actor exposes, it is one request. Point the URL at &lt;code&gt;https://mcp.apify.com/?actors=&amp;lt;username&amp;gt;/&amp;lt;actor&amp;gt;&lt;/code&gt;, run &lt;code&gt;tools/list&lt;/code&gt;, and read what comes back as if you had never seen your own input form. I did, and I found four things to fix in an afternoon.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>Phone Number Validation Without a Paid API — What the NANPA File Actually Tells You</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:50:19 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/phone-number-validation-without-a-paid-api-what-the-nanpa-file-actually-tells-you-4be9</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/phone-number-validation-without-a-paid-api-what-the-nanpa-file-actually-tells-you-4be9</guid>
      <description>&lt;p&gt;Most phone validation in the wild is a regex that counts digits. It passes &lt;code&gt;+1 999 123 4567&lt;/code&gt;, which can never ring, and it rejects nothing that matters.&lt;/p&gt;

&lt;p&gt;There is a real specification underneath, and the assignment data is public. This is what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file
&lt;/h2&gt;

&lt;p&gt;The North American Numbering Plan Administrator publishes the current assignment of every area code as a CSV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://reports.nanpa.com/public/npa_report.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no signup. It has 800 rows — one per possible area code — with 32 columns. The ones that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NPA_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the three-digit area code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IN_SERVICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Y&lt;/code&gt; or &lt;code&gt;N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;USE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;G&lt;/code&gt; geographic, &lt;code&gt;N&lt;/code&gt; non-geographic (toll-free and similar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPLANATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;e.g. &lt;code&gt;Expansion Code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOCATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;state, province or territory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COUNTRY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;US&lt;/code&gt;, &lt;code&gt;CANADA&lt;/code&gt;, &lt;code&gt;BAHAMAS&lt;/code&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TIME_ZONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;E&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt;, &lt;code&gt;M&lt;/code&gt;, &lt;code&gt;P&lt;/code&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Counting the file as of 4 September 2026: &lt;strong&gt;378 geographic area codes in service in the United States, and 454 across the whole plan&lt;/strong&gt; once Canada and the Caribbean members are included. If a tool tells you "378 area codes" while also promising Canadian coverage, it counted the wrong column.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules a regex misses
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The area code has to exist.&lt;/strong&gt; &lt;code&gt;999&lt;/code&gt; does not. It is one of &lt;strong&gt;79 codes with a 9 in the middle position&lt;/strong&gt; that the file marks &lt;code&gt;Expansion Code&lt;/code&gt; — held back for the day the plan runs out of numbers. Every one of them fails, and every one of them passes a length check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Area code and exchange both start with 2–9.&lt;/strong&gt; Never 0 or 1. This one most people do get right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The N11 codes are service codes.&lt;/strong&gt; &lt;code&gt;911&lt;/code&gt;, &lt;code&gt;411&lt;/code&gt;, &lt;code&gt;211&lt;/code&gt;, &lt;code&gt;311&lt;/code&gt;, &lt;code&gt;511&lt;/code&gt;, &lt;code&gt;611&lt;/code&gt;, &lt;code&gt;711&lt;/code&gt;, &lt;code&gt;811&lt;/code&gt; — never assignable as an exchange.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And then there is 555.&lt;/strong&gt; Here is where almost everyone overcorrects. The block reserved for fiction is narrow: &lt;strong&gt;&lt;code&gt;555-0100&lt;/code&gt; through &lt;code&gt;555-0199&lt;/code&gt;&lt;/strong&gt;, and only that. &lt;code&gt;555-1212&lt;/code&gt; is live directory assistance across the continent. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wrong — rejects a working number
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;555&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# right — rejects only the reserved block
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;555&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;199&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A validator that rejects the whole &lt;code&gt;555&lt;/code&gt; exchange will quietly throw away real numbers from a real list, and you will never see it happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a validated number is worth
&lt;/h2&gt;

&lt;p&gt;Once a number passes, the same file hands you two fields that turn a list into a call plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LOCATION&lt;/code&gt;&lt;/strong&gt; — the state or province of the area code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TIME_ZONE&lt;/code&gt;&lt;/strong&gt; — which is what you actually wanted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sorting a call list by the recipient's local time is the difference between a 9 a.m. call and a 6 a.m. one. One gotcha to hard-code: &lt;strong&gt;Arizona does not observe daylight saving.&lt;/strong&gt; All five Arizona area codes are marked Mountain, but for eight months of the year they are an hour off Denver. Use &lt;code&gt;America/Phoenix&lt;/code&gt;, not &lt;code&gt;America/Denver&lt;/code&gt;, or half your calls land at the wrong hour. (The Navajo Nation inside Arizona &lt;em&gt;does&lt;/em&gt; observe DST, which is the kind of edge case worth knowing exists and not worth solving.)&lt;/p&gt;

&lt;p&gt;Area codes that straddle two or three time zones exist too — a couple of dozen of them carry combinations like &lt;code&gt;CM&lt;/code&gt; or &lt;code&gt;EC&lt;/code&gt; in the file. Treat those as "confirm before dialling", not as a single zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real output
&lt;/h2&gt;

&lt;p&gt;An actual run, four numbers, September 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 415 555 0132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserved_exchange"&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;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 999 123 4567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nanp_rule"&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;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 646 555 0100"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserved_exchange"&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;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+62 361 123456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"international"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"structure_only"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneE164"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+62361123456"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last one is the honest part. &lt;code&gt;0361&lt;/code&gt; is genuinely the Denpasar area code, so the number is correctly formed for Indonesia — but outside the NANP there is no equivalent public assignment file to check against. &lt;code&gt;structure_only&lt;/code&gt; says exactly that. Any tool that claims to "validate" an arbitrary international number without saying which check it ran is telling you it checked something it did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not give you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Line type.&lt;/strong&gt; Whether a number is mobile, landline or VoIP is not in the assignment file. Carrier and line type come from HLR lookups, which are a paid product with per-number pricing, and the answer changes when someone ports their number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether it rings.&lt;/strong&gt; Nothing short of dialling tells you that, and dialling to find out is its own problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ownership.&lt;/strong&gt; Reverse lookup is a separate, and in most places regulated, product.&lt;/p&gt;

&lt;p&gt;What the file does give you is the cheap half: everything that can never be a working number, removed before you pay anyone per lookup. On scraped and bought lists that is a meaningful slice, and it costs a DNS-free, API-free file read.&lt;/p&gt;

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

&lt;p&gt;The &lt;a href="https://apify.com/lergassy/email-phone-verifier" rel="noopener noreferrer"&gt;Email Verifier &amp;amp; Phone Number Validator&lt;/a&gt; actor applies every rule above, plus the same treatment for email addresses in the same pass:&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;"phones"&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;"+1 415 555 0132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 646 212 3344"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+62 361 123456"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultCountry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="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;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~email-phone-verifier/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_APIFY_TOKEN"&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;'{"phones":["+1 415 555 0132","+1 646 212 3344"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also reads another actor's dataset directly, so a scraped lead list can be validated where it already sits:&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;"inputDatasetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"keep"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valid_phone"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;If you build your own, the file is the whole trick: download it, index by &lt;code&gt;NPA_ID&lt;/code&gt;, and check &lt;code&gt;IN_SERVICE&lt;/code&gt; before anything else. It updates as codes come into service, so re-fetch it monthly rather than freezing a copy in your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actor:&lt;/strong&gt; &lt;a href="https://apify.com/lergassy/email-phone-verifier" rel="noopener noreferrer"&gt;Email Verifier &amp;amp; Phone Number Validator&lt;/a&gt; on Apify.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>data</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Same Seat, Three Prices: Reading Google Flights Booking Options in Python</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Mon, 07 Sep 2026 03:20:12 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/the-same-seat-three-prices-reading-google-flights-booking-options-in-python-2gla</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/the-same-seat-three-prices-reading-google-flights-booking-options-in-python-2gla</guid>
      <description>&lt;p&gt;Here is a Jetstar flight from Bali to Singapore on 19 November, as Google Flights sees it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jetstar — 50 USD
       50 USD  (870000 IDR)         Jetstar [airline]
      119 USD  (2085182 IDR)        Qantas [airline]
      128 USD  (2251997 IDR)        Jettzy [agency]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same aircraft, same seat, same day. Fifty dollars if you buy from Jetstar, a hundred and&lt;br&gt;
twenty-eight if you buy from an agency that resells it. Google knows this and shows it&lt;br&gt;
behind a click most people never make.&lt;/p&gt;

&lt;p&gt;If you are building anything that compares fares — a price alert, a travel dashboard, an&lt;br&gt;
agent that answers "when should I fly" — that seller table is the interesting part, and&lt;br&gt;
it is the part most flight scrapers drop.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why there is no easy API for this
&lt;/h2&gt;

&lt;p&gt;Google shut down QPX Express in April 2018 and never replaced it. There is no public&lt;br&gt;
Google Flights API. What exists is the consumer interface, which is a heavily obfuscated&lt;br&gt;
single-page app, and the endpoint behind it, which returns a nested array of arrays with&lt;br&gt;
no field names at all.&lt;/p&gt;

&lt;p&gt;You can reverse that yourself — people do — or you can call something that already has.&lt;br&gt;
I maintain a &lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;Google Flights Scraper&lt;/a&gt;&lt;br&gt;
Actor on Apify that reads Google's own endpoint and returns flat rows. The three scripts&lt;br&gt;
below use it, and everything printed here is a real run, not an illustration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;apify-client
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...   &lt;span class="c"&gt;# console.apify.com/settings/integrations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the seller table
&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;os&lt;/span&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="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_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;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/google-flights-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;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;2026-11-19&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;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolveBookingOptions&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="c1"&gt;# the part that costs an extra request per flight
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxBookingResolutions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;default_dataset_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;flight&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;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flight&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;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;bookingOptions&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="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;airline&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="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flight&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="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;flight&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bookingOptions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;airline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isAirline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agency&lt;/span&gt;&lt;span class="sh"&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="s"&gt;   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;option&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="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; USD  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;seller&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="s"&gt; [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail worth knowing: &lt;code&gt;apify-client&lt;/code&gt; 3.x returns a &lt;code&gt;Run&lt;/code&gt; object, not a dictionary.&lt;br&gt;
Most tutorials online still show &lt;code&gt;run["defaultDatasetId"]&lt;/code&gt;, which now raises&lt;br&gt;
&lt;code&gt;TypeError: 'Run' object is not subscriptable&lt;/code&gt;. It is &lt;code&gt;run.default_dataset_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each booking option also carries &lt;code&gt;localPrice&lt;/code&gt; and &lt;code&gt;localCurrency&lt;/code&gt; — what the seller&lt;br&gt;
charges in the currency of the point of sale — plus a direct booking URL. The local price&lt;br&gt;
is not a conversion of the USD figure; it is the number that seller actually bills, and&lt;br&gt;
the two drift apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other two questions worth asking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Which day is cheapest?&lt;/strong&gt; Google Flights has a price calendar in its UI. Ask for it as&lt;br&gt;
rows and you can diff today's answer against yesterday's, which is what a fare alert&lt;br&gt;
actually needs:&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;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/google-flights-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;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;2026-11-10&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;calendarDays&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calendarOnly&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;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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;default_dataset_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;
&lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&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;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calendar_day&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;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;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;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;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# 2026-11-19 50
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where can I go cheaply from here?&lt;/strong&gt; The Explore map, as a sortable table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     63 USD  Komodo                 Indonesia        Wings Abadi Airlines nonstop    1.5h
     91 USD  Yogyakarta             Indonesia        Lion                 nonstop    1.5h
    107 USD  Perth                  Australia        Jetstar              nonstop    3.6h
    190 USD  Brisbane               Australia        Batik Air            nonstop    5.8h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;exploreAnywhere: True&lt;/code&gt; with an origin and a date, and each destination comes back&lt;br&gt;
with city, country, coordinates, price, airline, stops and flight time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that will bite you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Point of sale changes the fare.&lt;/strong&gt; A route priced from the US and the same route priced&lt;br&gt;
from Indonesia are genuinely different numbers, not a currency conversion. If you are&lt;br&gt;
comparing prices over time, pin &lt;code&gt;market&lt;/code&gt; to a two-letter country code, or you will record&lt;br&gt;
noise as signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A past date returns nothing, silently.&lt;/strong&gt; Google sells future dates only. Any scheduled&lt;br&gt;
job with a hardcoded departure date will quietly start returning empty results the day&lt;br&gt;
that date passes — worth a guard in your own code regardless of which tool you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scripts
&lt;/h2&gt;

&lt;p&gt;All three, runnable, are on GitHub:&lt;br&gt;
&lt;a href="https://github.com/lergassy/google-flights-api-examples" rel="noopener noreferrer"&gt;lergassy/google-flights-api-examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Actor itself, with the full field list — layovers, aircraft, CO₂ per itinerary,&lt;br&gt;
Google's own price level and the historical band behind it, multi-city, batch routes —&lt;br&gt;
is at &lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;apify.com/lergassy/google-flights-scraper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you end up building the fare alert, the seller table is where the money is. A hundred&lt;br&gt;
and twenty-eight dollars for a fifty dollar seat is not an edge case; it is Tuesday.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webscraping</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Email Verification Without Sending a Single Email (And Why Port 25 Is a Dead End)</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:33:29 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/email-verification-without-sending-a-single-email-and-why-port-25-is-a-dead-end-13ie</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/email-verification-without-sending-a-single-email-and-why-port-25-is-a-dead-end-13ie</guid>
      <description>&lt;p&gt;Every email verification service advertises the same thing: paste a list, get back deliverable or not. Almost none of them explain what they actually check, and the difference matters, because one of the checks people assume is happening is usually impossible.&lt;/p&gt;

&lt;p&gt;Here's what's really under the hood, what each signal is worth, and the one that quietly doesn't work on any major cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that doesn't work anymore
&lt;/h2&gt;

&lt;p&gt;The textbook method is an SMTP handshake. You connect to the recipient's mail server on port 25, say &lt;code&gt;HELO&lt;/code&gt;, &lt;code&gt;MAIL FROM&lt;/code&gt;, then &lt;code&gt;RCPT TO: &amp;lt;the address&amp;gt;&lt;/code&gt;, and read the response code. A 250 means the mailbox exists. You disconnect before &lt;code&gt;DATA&lt;/code&gt;, so no mail is ever sent.&lt;/p&gt;

&lt;p&gt;It's elegant. It's also unavailable to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound port 25 is blocked by default on AWS, Google Cloud, Azure, DigitalOcean, Hetzner and most serverless platforms.&lt;/strong&gt; It's an anti-spam measure and it has been standard for years. Some providers will unblock it — AWS after a request form, Google automatically for projects it scores as low-risk, Hetzner's dedicated servers by self-service after a month. DigitalOcean blocks 25, 465 and 587 with no path at all.&lt;/p&gt;

&lt;p&gt;The point isn't that it's impossible everywhere. It's that on whatever you actually deploy to, it's probably off, and finding out after you've built the feature is expensive. I checked before building anything: on the platform I deploy to, a raw socket to &lt;code&gt;gmail-smtp-in.l.google.com:25&lt;/code&gt; times out from every region I tried.&lt;/p&gt;

&lt;p&gt;Two more reasons SMTP verification is worse than it looks even when you can run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Catch-all domains accept everything.&lt;/strong&gt; A large share of corporate domains return 250 for any address at all, so a positive result carries no information.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greylisting and reputation.&lt;/strong&gt; Repeated &lt;code&gt;RCPT TO&lt;/code&gt; probes from one IP get throttled, then blocked. Providers treat the pattern as harvesting, because it is the same pattern harvesters use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is why the honest services return a &lt;strong&gt;three-state answer&lt;/strong&gt;, not a boolean. Anyone selling you a clean yes/no on a corporate domain is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can actually be checked
&lt;/h2&gt;

&lt;p&gt;Five signals, each independently useful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Syntax against the real grammar.&lt;/strong&gt; Not a regex from Stack Overflow. Length limits (64 characters local part, 254 total), consecutive dots, leading and trailing dots, quoting rules, IDN handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Domain resolution.&lt;/strong&gt; Does the domain exist at all? An &lt;code&gt;NXDOMAIN&lt;/code&gt; is a definite negative and the only cheap definite negative you get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. MX records.&lt;/strong&gt; A domain can exist and still have no mail exchanger. No MX means nothing can receive mail there, which is again definite. This one catches more than you'd expect, including a surprising number of live business websites whose owners never finished setting up mail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Disposable domain list.&lt;/strong&gt; Mailinator, Guerrilla Mail, 10minutemail and several thousand throwaway providers, including the typo-squat domains registered specifically to catch mistyped Gmail addresses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Classification.&lt;/strong&gt; Role address (&lt;code&gt;info@&lt;/code&gt;, &lt;code&gt;support@&lt;/code&gt;, &lt;code&gt;sales@&lt;/code&gt;, &lt;code&gt;noreply@&lt;/code&gt;), free mailbox (Gmail, Yahoo, Outlook), and a typo suggestion when the domain is one edit away from a major provider.&lt;/p&gt;

&lt;p&gt;The output of those five is not "valid" or "invalid". It's:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;deliverable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Domain resolves, has an MX, not disposable, syntax clean&lt;/td&gt;
&lt;td&gt;Send&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;risky&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reachable but flagged — disposable domain, or no MX record&lt;/td&gt;
&lt;td&gt;Decide by campaign&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;undeliverable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Domain doesn't exist, or syntax is invalid&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Role and free-mailbox are &lt;strong&gt;flags, not statuses&lt;/strong&gt;. &lt;code&gt;support@apify.com&lt;/code&gt; is deliverable &lt;em&gt;and&lt;/em&gt; a role address; whether that disqualifies it depends entirely on what you're sending. The status says whether mail can arrive. The flags say whether you want it to.&lt;/p&gt;

&lt;p&gt;The reason a missing MX lands in &lt;em&gt;risky&lt;/em&gt; rather than &lt;em&gt;undeliverable&lt;/em&gt; is a detail of the standard: with no MX record, a sending server falls back to the domain's A record and tries to deliver there (RFC 5321 §5.1). That almost always fails, because the machine behind the A record is a web server with no mail daemon — but "almost always" isn't "never", so the honest label is risky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real output
&lt;/h2&gt;

&lt;p&gt;This is an actual run, not a mockup. Six addresses and four phone numbers went in on 4 September 2026; four of the email results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support@apify.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deliverable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apify.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"emailIsRole"&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;"emailIsFree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"emailIsDisposable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contactScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fake@nonexistentdomain-xyz-99.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"undeliverable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"domain_not_found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contactScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test@mailinator.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"risky"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disposable_domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailIsDisposable"&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;"contactScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noreply@gmial.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"risky"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disposable_domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emailIsRole"&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;"emailIsDisposable"&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;"contactScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is the interesting case. &lt;code&gt;gmial.com&lt;/code&gt; is a misspelling of &lt;code&gt;gmail.com&lt;/code&gt;, it is registered, it resolves, and it sits on the public disposable-domain blocklists. A syntax check passes it. A domain-resolution check passes it. It takes the blocklist — or, independently, the missing MX record — to flag it, which is the argument for running all five checks rather than the one you think is decisive.&lt;/p&gt;

&lt;p&gt;One more from that run, which I'll return to at the end: a live business domain that came back &lt;strong&gt;risky, reason &lt;code&gt;no_mx_record&lt;/code&gt;&lt;/strong&gt;. Working website, working DNS, no mail exchanger. Every email sent to that domain bounces, and its owner had no idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phone numbers: the rules are published
&lt;/h2&gt;

&lt;p&gt;North American phone validation has a real specification, which means real verification instead of a regex.&lt;/p&gt;

&lt;p&gt;The North American Numbering Plan Administrator publishes the assignment file at &lt;code&gt;reports.nanpa.com/public/npa_report.csv&lt;/code&gt;. It lists every area code, whether it's geographic, which state, province or territory it belongs to, and its time zone. In the file dated 3 September 2026 there are &lt;strong&gt;378 geographic area codes in the United States&lt;/strong&gt;, and &lt;strong&gt;454 across the whole plan&lt;/strong&gt; once Canada and the Caribbean members are counted.&lt;/p&gt;

&lt;p&gt;From that file plus the plan's own rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The area code must be &lt;strong&gt;assigned and in service&lt;/strong&gt;. &lt;code&gt;999&lt;/code&gt; is not: it's one of &lt;strong&gt;79 codes with a 9 in the middle&lt;/strong&gt; that the file marks &lt;code&gt;Expansion Code&lt;/code&gt;, held back for when the plan runs out of numbers. Every one of them fails.&lt;/li&gt;
&lt;li&gt;Area code and exchange both start with &lt;strong&gt;2 through 9&lt;/strong&gt;, never 0 or 1.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;N11 codes are service codes&lt;/strong&gt; — &lt;code&gt;911&lt;/code&gt;, &lt;code&gt;411&lt;/code&gt;, &lt;code&gt;211&lt;/code&gt; and the rest — and are never assignable as exchanges. A handful more, &lt;code&gt;555&lt;/code&gt;, &lt;code&gt;950&lt;/code&gt;, &lt;code&gt;958&lt;/code&gt;, &lt;code&gt;959&lt;/code&gt; and &lt;code&gt;976&lt;/code&gt;, are held back by convention rather than by rule.&lt;/li&gt;
&lt;li&gt;Within &lt;code&gt;555&lt;/code&gt;, only the block &lt;strong&gt;&lt;code&gt;555-0100&lt;/code&gt; to &lt;code&gt;555-0199&lt;/code&gt;&lt;/strong&gt; is formally set aside for fictional use. This is the part most validators get wrong in the other direction: &lt;code&gt;555-1212&lt;/code&gt; is a live directory-assistance number, so "reject anything with 555" produces false negatives on real data. Reject the reserved block, not the exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real output from the same run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 415 555 0132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserved_exchange"&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;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 999 123 4567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nanp_rule"&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;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+62 361 123456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"international"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phoneReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"structure_only"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phoneE164"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+62361123456"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the third one. &lt;code&gt;0361&lt;/code&gt; is genuinely the Denpasar area code, so the number is correctly formed for Bali, and the honest answer is &lt;code&gt;structure_only&lt;/code&gt; — the number is correctly formed, and outside the NANP there is no equivalent public assignment file to check it against. A tool that claims to "validate" any international number without saying that is telling you it checked something it didn't.&lt;/p&gt;

&lt;p&gt;For valid North American numbers you also get the state and the time zone, which is what you actually wanted — a lead list sorted by local business hours. One gotcha worth hard-coding: Arizona does not observe daylight saving, so it needs &lt;code&gt;America/Phoenix&lt;/code&gt;, not &lt;code&gt;America/Denver&lt;/code&gt;, or half your calls land an hour off for eight months of the year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who needs this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Anyone buying or scraping lead lists.&lt;/strong&gt; Bounce rate is what gets a sending domain blacklisted. The clearest published numbers come from Amazon SES: a hard-bounce rate at or above &lt;strong&gt;5% puts an account under review, 10% suspends it&lt;/strong&gt;. Most other providers publish nothing and act on their own thresholds, which is worse, not better — you find out by being throttled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sales teams paying per contact.&lt;/strong&gt; Enrichment vendors charge whether the contact is alive or not. Filtering before import is cheaper than filtering after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone with a signup form.&lt;/strong&gt; Blocking disposable domains at registration removes most throwaway accounts, and the typo suggestion recovers real users who would otherwise never receive the confirmation email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRM hygiene.&lt;/strong&gt; Contact data goes stale continuously as people change jobs, companies fold and domains lapse. The often-quoted "22.5% a year" traces back to a single HubSpot blog post and shouldn't be treated as measured, but the direction isn't in doubt, and a quarterly pass over the whole database catches the decay before a campaign does.&lt;/p&gt;

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

&lt;p&gt;The &lt;a href="https://apify.com/lergassy/email-phone-verifier" rel="noopener noreferrer"&gt;Email Verifier &amp;amp; Phone Number Validator&lt;/a&gt; actor does the checks above with no SMTP dependency, which is why it works from anywhere. It runs bulk email verification and phone number validation in the same pass, over a pasted list, a CSV, or another actor's dataset.&lt;/p&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"support@apify.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test@mailinator.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&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;"+1 415 555 0132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 212 555 0199"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keep"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the API:&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~email-phone-verifier/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_APIFY_TOKEN"&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;'{"emails":["support@apify.com"],"phones":["+1 415 555 0132"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also reads another actor's dataset directly, which is the part worth knowing. Point &lt;code&gt;inputDatasetId&lt;/code&gt; at the output of any scraper that produced contacts and it verifies that dataset in place:&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;"inputDatasetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emailField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phoneField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keep"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deliverable"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;keep&lt;/code&gt; to &lt;code&gt;deliverable&lt;/code&gt; and the output contains only contacts that passed. Scrape, verify, import, with nothing in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;It does not confirm that a specific mailbox exists. Nothing that runs on ordinary cloud infrastructure can, for the port 25 reason above, and on catch-all domains nothing can regardless of where it runs.&lt;/p&gt;

&lt;p&gt;It does not tell you whether a phone is mobile or landline. That requires a carrier lookup against paid HLR data, which is a different product at a different price.&lt;/p&gt;

&lt;p&gt;It does not check whether a person still works somewhere. That's job-change data, sold by LinkedIn-adjacent vendors, and no amount of DNS will produce it.&lt;/p&gt;

&lt;p&gt;What it does is remove the addresses that will definitely bounce and the numbers that cannot possibly ring, and label the rest with the reason. On the scraped lists I've run it through, that has been somewhere between a seventh and a third of the rows — and it's that fraction which costs you a sending domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The postscript
&lt;/h2&gt;

&lt;p&gt;The domain that came back &lt;code&gt;no_mx_record&lt;/code&gt; in the run above belongs to a real, working business. Its website loads, its DNS resolves, and mail was simply never configured.&lt;/p&gt;

&lt;p&gt;A sender with no MX record to aim at falls back to the domain's A record and tries to deliver there. That machine is a web server which has never spoken SMTP, so the message fails — quietly, for as long as the domain has existed. Nobody at that company knew, because a bounce lands in the sender's inbox, not theirs.&lt;/p&gt;

&lt;p&gt;There is a decent chance this is true of a domain you own. Run your own addresses through something before you run anyone else's.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Actor:&lt;/strong&gt; &lt;a href="https://apify.com/lergassy/email-phone-verifier" rel="noopener noreferrer"&gt;Email Verifier &amp;amp; Phone Number Validator&lt;/a&gt; on Apify&lt;/p&gt;

&lt;p&gt;Related: &lt;a href="https://apify.com/lergassy/us-business-filings" rel="noopener noreferrer"&gt;US New Business Leads&lt;/a&gt; delivers newly registered US companies with these same checks already applied to every phone and email in the feed.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>Telegram Channel Analytics Without an API Key — Subscriber Counts Lie, Reach Doesn't</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:33:13 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/telegram-channel-analytics-without-an-api-key-subscriber-counts-lie-reach-doesnt-4nd6</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/telegram-channel-analytics-without-an-api-key-subscriber-counts-lie-reach-doesnt-4nd6</guid>
      <description>&lt;p&gt;A Telegram channel with 10.9 million subscribers and a channel with 172,000 subscribers. Which one would you advertise in?&lt;/p&gt;

&lt;p&gt;Here are their actual numbers, from a run over the last 100 posts of each on 4 September 2026:&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;Pavel Durov&lt;/th&gt;
&lt;th&gt;Bloomberg&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subscribers&lt;/td&gt;
&lt;td&gt;10,900,000&lt;/td&gt;
&lt;td&gt;172,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median views per post&lt;/td&gt;
&lt;td&gt;5,600,000&lt;/td&gt;
&lt;td&gt;39,900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reach&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reach trend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−10.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+44.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reactions per post&lt;/td&gt;
&lt;td&gt;1,030&lt;/td&gt;
&lt;td&gt;144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts per day&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Durov's channel reaches half its audience — extraordinary, and 140× more people in absolute terms. But its reach is &lt;em&gt;falling&lt;/em&gt;, while Bloomberg's is up 45% over the same window. Depending on whether you're buying one placement or a six-month presence, those two facts point in different directions.&lt;/p&gt;

&lt;p&gt;None of this is in the subscriber count, which is the only number most people look at. All of it is available without an API key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways into Telegram data, and why two of them don't work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bot API.&lt;/strong&gt; Requires your bot to be an administrator of the target channel. Perfect for channels you own, useless for every channel you don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MTProto / user API.&lt;/strong&gt; Full access to everything a logged-in user sees. Requires a phone number, a 2FA session and careful rate limiting. Reading public channel history through it is relatively low-risk; bulk member-list exports and messaging strangers are what actually get accounts limited. Either way you are putting a real phone number behind the request, and a restriction lands on the number, not the IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The public preview page.&lt;/strong&gt; Telegram serves &lt;code&gt;t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt; to any visitor, no account required. It's the same HTML a search engine sees.&lt;/p&gt;

&lt;p&gt;For monitoring channels you don't own, the third option is the only sane one. What it gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Channel header: title, subscriber count, description, photo, verified badge, media counts&lt;/li&gt;
&lt;li&gt;20 posts per request, paged backwards with &lt;code&gt;?before=&amp;lt;message_id&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Per post: message id, ISO timestamp, full text, &lt;strong&gt;view count&lt;/strong&gt;, reactions with their emoji and counts, media flags, forward source, reply flag, author signature&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About 1.4 seconds per page, so 200 posts — ten pages — in roughly fifteen seconds. No token, no proxy required, no ban risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why views are the number that matters
&lt;/h2&gt;

&lt;p&gt;Telegram is unusual in how far it goes here: every post in a public channel carries a view count, visible to anyone, with no login and no API. X and Instagram both surface view counts too, but only inside their own apps, behind authentication and rate limits — which is the difference that matters if you want to compute anything across a list of channels.&lt;/p&gt;

&lt;p&gt;That single number lets you compute the thing advertisers actually price against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reach = median views per post / subscribers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Median, never average.&lt;/strong&gt; One viral post drags an average up by a factor of three and tells you nothing about what a normal post does. On the Durov channel the average is 6.66M against a 5.6M median, and the best post did 24.2M — if you quoted the average to a client you would be promising reach that a typical post won't deliver.&lt;/p&gt;

&lt;p&gt;Reach separates real audiences from bought ones instantly. A channel with 200,000 subscribers and 4% reach was inflated. A channel with 50,000 subscribers and 40% reach is worth more per post than the inflated one, and usually costs less.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else the timestamps give you
&lt;/h2&gt;

&lt;p&gt;Once you have a few hundred posts with timestamps and view counts, several useful things fall out of simple arithmetic:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach trend.&lt;/strong&gt; Median views of the newer half of the window against the older half. Bloomberg's +44.9% and Durov's −10.4% above come from exactly this. It's a direction, not a forecast, but it's the difference between buying into a channel on the way up and one on the way down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best hours and weekdays.&lt;/strong&gt; Group posts by hour, take the median views of each group, and require at least two posts per group so a single outlier can't win. This is a channel's own data, which beats every generic "best time to post" article ever written, because those articles are about a different audience in a different time zone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cadence.&lt;/strong&gt; Posts per day and per week. A channel posting fifty times a day burns its audience differently from one posting twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content mix.&lt;/strong&gt; Share of posts with photos, videos, polls, links. Share that are forwards, and which channels they're forwarded from — run that across twenty channels in one niche and you have a map of who feeds whom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reaction rate.&lt;/strong&gt; Reactions per post as a share of median views. Rough, but it separates channels that are read from channels that are merely delivered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes worth avoiding
&lt;/h2&gt;

&lt;p&gt;I got most of these wrong before the data corrected me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A slow channel's 100 posts can span a year.&lt;/strong&gt; Bloomberg's channel above posts 0.2 times a day, so 100 posts covers well over a year. Any "per day" figure computed over that window is an average across seasons. Always publish the number of days the window covers alongside anything rated per day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hashtag extraction needs a letter.&lt;/strong&gt; A naive &lt;code&gt;#\w+&lt;/code&gt; regex turns &lt;code&gt;#036&lt;/code&gt; from a price into a top hashtag. Require at least one letter in the match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach above 100% is not a bug.&lt;/strong&gt; Telegram counts a view once per user, including people who saw the post forwarded into another channel or opened it on the web. A post that travels beyond its channel can be seen by more people than the channel has subscribers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamps are UTC.&lt;/strong&gt; Convert to the channel's own time zone before concluding anything about a morning slot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal implementation
&lt;/h2&gt;

&lt;p&gt;The page is server-rendered HTML with stable class names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;channel_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://t.me/s/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?before=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;html&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&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;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_wrap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time_el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tgme_widget_message_date time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[data-post]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data-post&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time_el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time_el&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things this snippet doesn't do, and you need both: view counts arrive as &lt;code&gt;53K&lt;/code&gt; and &lt;code&gt;1.4M&lt;/code&gt;, so they need parsing into integers before any arithmetic; and paging requires passing the &lt;em&gt;lowest&lt;/em&gt; message id you've seen back as &lt;code&gt;before&lt;/code&gt;, then stopping when a page returns nothing new.&lt;/p&gt;

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

&lt;p&gt;I packaged the whole thing — paging, the K/M parser, all the metrics above, error rows for channels that have no public page — as &lt;a href="https://apify.com/lergassy/telegram-channel-analytics" rel="noopener noreferrer"&gt;Telegram Channel Analytics&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@durov"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@bloomberg"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postsPerChannel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topPosts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per channel, with a ready-made sentence at the end of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bloomberg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subscribers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;172000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postsAnalysed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"daysCovered"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;415.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;"viewsMedian"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;39900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engagementRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;23.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;"viewsTrendPercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;44.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;"reactionsPerPost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;143.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;"bestHours"&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;"hour"&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;"posts"&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;"medianViews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;62300&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;"Bloomberg has 172,000 subscribers, median post reaches 39,900 views (23.2% of the audience), posts 0.2 times a day, reach up 44.9% across the 100 posts analysed."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One naming wrinkle worth flagging: the field is called &lt;code&gt;engagementRate&lt;/code&gt;, but it holds what this article calls &lt;strong&gt;reach&lt;/strong&gt; — median views over subscribers. The reaction rate is a separate field.&lt;/p&gt;

&lt;p&gt;Switch on &lt;code&gt;includePosts&lt;/code&gt; and every post arrives as its own row too, with views, reach, reactions, media flags, hashtags and links.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Private channels and groups are invisible.&lt;/strong&gt; No public page, no data. Those come back as an explicit error row with the reason, never as a silent zero. Reading private chats requires a real account and a session, which is a different product with different risks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscriber history doesn't exist in the source.&lt;/strong&gt; Telegram publishes the current count and nothing else. "How fast is this channel growing" can only be answered by running on a schedule and keeping the results — a daily run turns &lt;code&gt;subscribers&lt;/code&gt;, &lt;code&gt;viewsMedian&lt;/code&gt; and &lt;code&gt;engagementRate&lt;/code&gt; into a time series. There is no way to recover last month's number today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments aren't in the preview.&lt;/strong&gt; Discussion-group replies live elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is worth the effort
&lt;/h2&gt;

&lt;p&gt;Buying advertising is the obvious one: compare candidates on reach instead of subscribers and the shortlist reorders immediately.&lt;/p&gt;

&lt;p&gt;Selling it is the mirror image. A channel owner quoting reach, reaction rate, cadence and best hours is quoting a media kit. Most don't, because nobody computes it for them.&lt;/p&gt;

&lt;p&gt;And competitor tracking, which is the use that compounds: run the same twenty channels weekly, watch whose reach is climbing, then look at what they changed in the same window. The content mix and posting hours are right there in the same row.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Actor:&lt;/strong&gt; &lt;a href="https://apify.com/lergassy/telegram-channel-analytics" rel="noopener noreferrer"&gt;apify.com/lergassy/telegram-channel-analytics&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>datascience</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Hotel Review Data in 2026 — Why Agoda Gives You Two Datasets for the Price of One</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:32:51 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/hotel-review-data-in-2026-why-agoda-gives-you-two-datasets-for-the-price-of-one-38ml</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/hotel-review-data-in-2026-why-agoda-gives-you-two-datasets-for-the-price-of-one-38ml</guid>
      <description>&lt;p&gt;If you're collecting hotel review data, Agoda is the source most people skip and the one with the most on it. Not because it has more reviews than Booking.com, but because it carries &lt;strong&gt;both&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Agoda was bought in 2007 by priceline.com, the company that renamed itself Booking Holdings in 2018 and also owns Booking.com, Kayak and OpenTable. One consequence is visible on almost every hotel page: Agoda displays its own guest reviews &lt;em&gt;and&lt;/em&gt; Booking.com's, served by separate review providers behind the same page.&lt;/p&gt;

&lt;p&gt;A real example, pulled this month:&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;"hotel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Magani Hotel And Spa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hotelId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;335650&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agodaReviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1347&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bookingReviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1025&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 2,372 reviews for one mid-sized Bali hotel as of 4 September 2026, from two review pools with different reviewer bases, reachable through one page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hotel review data is harder than it looks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;There is no public API.&lt;/strong&gt; Agoda has an affiliate and partner program with data access for approved partners. For everyone else — an analyst, a hotel owner benchmarking competitors, a developer building a comparison tool — there's the website.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review counts on the page don't match what the page shows.&lt;/strong&gt; A hotel page displays a paginated slice of reviews. The provider structure isn't visible in the HTML — you have to know that provider &lt;code&gt;332&lt;/code&gt; is Agoda's own pool and &lt;code&gt;3038&lt;/code&gt; is Booking.com's, and request each separately, or you silently collect half the data and never know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interesting fields aren't in the review text.&lt;/strong&gt; Rating and comment are the easy part. Traveler type, room type booked, length of stay and reviewer country are what make the data analysable, and they're attached to the review record rather than rendered as prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prices and availability are dynamic; reviews are not.&lt;/strong&gt; This is the part that makes reviews the more useful target. A scraped price is stale in an hour. A review stays true to what its author wrote, give or take the occasional edit or deletion, which means a review dataset compounds while a price dataset decays.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a review record actually contains
&lt;/h2&gt;

&lt;p&gt;Real output, one review, unedited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hotelId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;335650&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hotelName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Magani Hotel And Spa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agoda"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1175631129&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Magani"&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;"Great location, lovely staff , great breakfast &amp;amp; comfy bed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"September 03, 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkInDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"August 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Natalie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewerCountry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"au"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"travelerType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Couple"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roomType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deluxe Room Double Bed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lengthOfStay"&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;"reviewProvider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Agoda"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"helpfulVotes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four fields there are worth more than the review text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;reviewerCountry&lt;/code&gt;&lt;/strong&gt; turns a review set into a demand map. Which markets actually book this property, and how do their ratings differ? Australian and Chinese guests rate the same Bali hotel differently, consistently, and if you run a property you need to know in which direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;travelerType&lt;/code&gt;&lt;/strong&gt; — Couple, Solo, Family with young children, Business — segments the ratings. A hotel averaging 8.4 might be a 9.1 for couples and a 6.8 for families, and the average hides the entire problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;roomType&lt;/code&gt;&lt;/strong&gt; attributes complaints to inventory. If the 4-star average is being dragged down by one room category, that's an operational fix, not a marketing one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lengthOfStay&lt;/code&gt;&lt;/strong&gt; separates the one-night airport stopover from the week-long stay. They are different products reviewed on the same page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;checkInDate&lt;/code&gt;&lt;/strong&gt; matters more than &lt;code&gt;reviewDate&lt;/code&gt;: it tells you when the guest was actually there, which is what you need to correlate a rating drop with a renovation, a management change or a rainy season.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who uses this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hotel operators benchmarking competitors.&lt;/strong&gt; Five properties in the same district, all their reviews for the last two years, segmented by traveler type. The gaps are specific and actionable in a way that a star rating never is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revenue managers.&lt;/strong&gt; Rating movements lead pricing power. A property whose cleanliness sub-score has fallen half a point over two quarters will lose rate before it loses occupancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investors and acquirers.&lt;/strong&gt; Reviews are the only public operational data on a private hotel. Trajectory over three years, complaint themes, and whether ratings recovered after the last renovation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Travel product builders.&lt;/strong&gt; Aggregating reviews across sources for a comparison site, a recommendation engine, or a chatbot that answers "quiet hotel in Ubud, good for families" from evidence instead of marketing copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentiment and NLP work.&lt;/strong&gt; Hotel reviews are unusually good training data: a numeric rating attached to free text, in dozens of languages, with structured metadata about who wrote it.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://apify.com/lergassy/agoda-reviews-scraper" rel="noopener noreferrer"&gt;Agoda Reviews Scraper&lt;/a&gt; takes hotel page URLs and handles both review providers.&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;"startUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.agoda.com/the-magani-hotel-and-spa/hotel/bali-id.html"&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;"maxReviewsPerHotel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsProvider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsSort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recent"&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;reviewsProvider&lt;/code&gt; accepts &lt;code&gt;agoda&lt;/code&gt;, &lt;code&gt;booking&lt;/code&gt; or &lt;code&gt;all&lt;/code&gt;. Use &lt;code&gt;all&lt;/code&gt; unless you specifically want one pool. Note that these are the two large providers, not the only ones — some properties also carry smaller pools from regional partners, which this run doesn't pull.&lt;/p&gt;

&lt;p&gt;From the API:&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~agoda-reviews-scraper/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_APIFY_TOKEN"&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;'{
    "startUrls": [{"url":"https://www.agoda.com/the-magani-hotel-and-spa/hotel/bali-id.html"}],
    "maxReviewsPerHotel": 200,
    "reviewsProvider": "all"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output is one &lt;code&gt;hotel&lt;/code&gt; row per property with both review counts, then one &lt;code&gt;review&lt;/code&gt; row per review. Sort options are recent, highest rating and lowest rating — and lowest-first is usually the more useful run, because the complaints cluster and the praise doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you don't get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No prices.&lt;/strong&gt; Review extraction and price extraction are separate problems, because prices depend on dates, occupancy and currency. For dated hotel pricing with the rate for your own check-in and check-out, that's &lt;a href="https://apify.com/lergassy/tripcom-scraper" rel="noopener noreferrer"&gt;Trip.com Scraper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No reviewer identity beyond a first name and country.&lt;/strong&gt; That's all Agoda publishes, and it's the right amount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hotel replies are hit and miss.&lt;/strong&gt; Agoda does publish management responses, but adoption varies wildly by property — some hotels answer nearly every review, plenty answer none. Don't build an analysis that assumes replies exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review counts vary by provider availability.&lt;/strong&gt; Some properties have a large Agoda pool and almost no Booking.com reviews, or the reverse. The &lt;code&gt;hotel&lt;/code&gt; row reports both counts before any reviews are pulled, so you can check coverage before committing to a large run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build versus buy, honestly
&lt;/h2&gt;

&lt;p&gt;The hard parts here aren't conceptually hard: find the hotel id from a URL slug, call the review endpoint per provider, page through, normalise dates. A competent developer does the first version in a day.&lt;/p&gt;

&lt;p&gt;The cost is the second year. Hotel ids get reassigned, the provider list changes per property, date formats differ by locale, and the endpoint shape shifts without notice. It's not difficult work, it's &lt;em&gt;unscheduled&lt;/em&gt; work, arriving on the day you need the data.&lt;/p&gt;

&lt;p&gt;The actor is free to run while it's new — you pay Apify's platform usage and nothing per review — which makes the build-versus-buy arithmetic hard to argue with. Build it if hotel data is your product. Don't if hotel data is an input to your product.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Actor:&lt;/strong&gt; &lt;a href="https://apify.com/lergassy/agoda-reviews-scraper" rel="noopener noreferrer"&gt;apify.com/lergassy/agoda-reviews-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Related: &lt;a href="https://apify.com/lergassy/tripcom-scraper" rel="noopener noreferrer"&gt;Trip.com Scraper&lt;/a&gt; for dated hotel prices and ratings, &lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;Google Flights Scraper&lt;/a&gt; for airfare on the same trip.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>data</category>
      <category>travel</category>
    </item>
    <item>
      <title>Google Flights Data Without an API — What You Can Actually Get in 2026</title>
      <dc:creator>Nikita Iakovlev</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:32:43 +0000</pubDate>
      <link>https://dev.to/nikita_iakovlev_415524c19/google-flights-data-without-an-api-what-you-can-actually-get-in-2026-52c3</link>
      <guid>https://dev.to/nikita_iakovlev_415524c19/google-flights-data-without-an-api-what-you-can-actually-get-in-2026-52c3</guid>
      <description>&lt;p&gt;Google Flights is the best flight search on the internet, and one of the few major Google products with no self-serve API attached to it. QPX Express, the public tier that used to do this, was withdrawn on 10 April 2018. The engine itself never went away — ITA's QPX Enterprise is still sold to airlines — but nothing replaced the tier a developer could just sign up for.&lt;/p&gt;

&lt;p&gt;What's left is the page. It's worth more than people assume, because it carries something the airlines themselves don't publish.&lt;/p&gt;

&lt;p&gt;Here is a real run, Bali to Singapore, departing 15 October 2026, pulled this month:&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;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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;"2026-10-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;"tripType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"one_way"&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;"flightsFound"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lowestPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"typical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typicalPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typicalPriceLow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typicalPriceHigh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceHistory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="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-07-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;54&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;"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-07-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;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;58&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;"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-07-07"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;58&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;priceLevel&lt;/code&gt;, &lt;code&gt;typicalPrice&lt;/code&gt; and &lt;code&gt;priceHistory&lt;/code&gt;. Google tracks what this route normally costs and tells you where today sits in that band. $82 against a typical $68, in a normal range of $55 to $110. It's "typical", not a deal.&lt;/p&gt;

&lt;p&gt;No airline gives you that. No aggregator gives you that for free. It's the single most useful field on the page and it exists because Google has been recording every route's fares for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there's no API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data isn't simply Google's to hand over.&lt;/strong&gt; By QPX's own documentation the inputs are schedules from OAG, published fares from ATPCO, and availability supplied directly or indirectly by the carriers. Google can compute and display itineraries from those inputs. Reselling the underlying feeds is a different question with a different set of licensors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The industry alternatives are expensive.&lt;/strong&gt; Amadeus and Sabre sell developer access, with per-call pricing, contracts and certification. Duffel and Kiwi are friendlier but priced for booking flows, not research: they expect you to sell tickets, not analyse fares.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The page is a heavy client-side app.&lt;/strong&gt; Google Flights renders results in the browser from an internal protocol buffer payload. There's no HTML table of flights to parse, which is why most naive attempts return an empty list and conclude the data isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results are personalised and regional.&lt;/strong&gt; Currency, available carriers and sometimes price depend on where the request comes from. A fare pulled from a US datacenter is not necessarily the fare a user in Jakarta sees, and if you're doing price comparison research that difference is your entire result.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get per flight
&lt;/h2&gt;

&lt;p&gt;Each flight in the results is a full itinerary, not a price tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isBestFlight"&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;"airline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scoot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"flightNumbers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TR289"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stops"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"departAirport"&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;"departAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15T14:05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arriveAirport"&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;"arriveAt"&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-15T16:55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalDurationMinutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;170&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="s2"&gt;"2 hr 50 min"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"flightNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TR289"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"airline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scoot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"airlineCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&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;"fromName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I Gusti Ngurah Rai International Airport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"to"&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;"toName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Singapore Changi Airport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"departAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15T14:05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"arriveAt"&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-15T16:55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"durationMinutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;170&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multi-leg itineraries come back with every leg, its operating carrier, and the layover between them — which matters, because a $40 saving that costs a nine-hour layover in Kuala Lumpur isn't a saving.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isBestFlight&lt;/code&gt; is Google's own flag combining price, duration and stops. Useful as a shortcut, and useful to &lt;em&gt;ignore&lt;/em&gt; deliberately when you're studying what a route actually costs rather than what Google recommends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's genuinely good for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price monitoring on routes you care about.&lt;/strong&gt; Run the same route daily, keep the results, and &lt;code&gt;priceLevel&lt;/code&gt; plus your own history tells you when to buy. This is the use case that pays for itself in one trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route research.&lt;/strong&gt; Which carriers fly a city pair, how often, at what times. For anyone planning capacity, a travel business, or a relocation, that's a survey nobody publishes in one place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fare benchmarking for travel agencies.&lt;/strong&gt; Comparing your GDS fare against the public best price on the same route, daily, tells you where your inventory is uncompetitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seasonality studies.&lt;/strong&gt; Run a route across a range of departure dates and the shape of the year appears — the shoulder seasons, the holiday spikes, the exact week a route gets expensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visa and immigration paperwork.&lt;/strong&gt; Several consulates want proof of onward travel. Knowing the real price band of the cheapest exit flight, for the right date, is the difference between a $60 ticket and a $300 one.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;Google Flights Scraper&lt;/a&gt; takes IATA codes and dates:&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;"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;"2026-10-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;"adults"&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="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;Round trips take &lt;code&gt;returnDate&lt;/code&gt;. Several routes in one run go in &lt;code&gt;routes&lt;/code&gt;. Open-jaw and multi-city itineraries go in &lt;code&gt;multiCityLegs&lt;/code&gt;. Cabin class, passenger mix and currency are all inputs.&lt;/p&gt;

&lt;p&gt;From the API:&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~google-flights-scraper/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_APIFY_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"origin":"DPS","destination":"SIN","departureDate":"2026-10-15","adults":1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output is one &lt;code&gt;search&lt;/code&gt; row per query — with the price band, the history and the totals — followed by one &lt;code&gt;flight&lt;/code&gt; row per itinerary. The search row alone answers "is this cheap right now", which for monitoring is the only row you need to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you don't get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No booking.&lt;/strong&gt; This is research data. Booking requires an airline or an OTA, and the fare you saw may be gone by the time you get there, which is normal for airline pricing rather than a defect in the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No seat availability or fare rules.&lt;/strong&gt; Change fees, baggage allowance and refundability aren't on the results page. They live in the fare rules, which are a GDS product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prices are indicative.&lt;/strong&gt; Google shows fares from its partners, and the same flight can be cheaper direct or on a regional OTA. Treat the number as a benchmark, not a quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Personalisation is real.&lt;/strong&gt; If you need the fare a specific market sees, the request has to originate in that market. Comparing "the DPS–SIN price" across regions requires deciding which region you mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest build-versus-buy note
&lt;/h2&gt;

&lt;p&gt;Parsing Google Flights is not a weekend project. The payload is a nested protocol buffer with no field names, positional rather than keyed, and the positions move when Google ships a change. Every published parser is a set of magic array indices that somebody re-derived by hand after the last break.&lt;/p&gt;

&lt;p&gt;If flight data is your core product, you'll end up owning that maintenance regardless. If it's one input among many, someone else's parser is the right answer — this one is currently free to run, with only Apify's platform usage to pay — and the two hours you'd spend on the first version are better spent on what you do with the numbers.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Actor:&lt;/strong&gt; &lt;a href="https://apify.com/lergassy/google-flights-scraper" rel="noopener noreferrer"&gt;apify.com/lergassy/google-flights-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Related: &lt;a href="https://apify.com/lergassy/agoda-reviews-scraper" rel="noopener noreferrer"&gt;Agoda Reviews Scraper&lt;/a&gt; and &lt;a href="https://apify.com/lergassy/tripcom-scraper" rel="noopener noreferrer"&gt;Trip.com Scraper&lt;/a&gt; for the hotel half of the same trip.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>travel</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
