<?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>Forem: SIÁN Agency</title>
    <description>The latest articles on Forem by SIÁN Agency (@sian-agency).</description>
    <link>https://forem.com/sian-agency</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3854792%2Fcb57fd08-1d47-4084-97aa-8c4879d72af0.png</url>
      <title>Forem: SIÁN Agency</title>
      <link>https://forem.com/sian-agency</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/sian-agency"/>
    <language>en</language>
    <item>
      <title>Rate Limits Are a Feature, Not a Bug</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Thu, 07 May 2026 05:39:33 +0000</pubDate>
      <link>https://forem.com/sian-agency/rate-limits-are-a-feature-not-a-bug-4lnm</link>
      <guid>https://forem.com/sian-agency/rate-limits-are-a-feature-not-a-bug-4lnm</guid>
      <description>&lt;p&gt;Most scraper "incidents" I'm pulled into start the same way: someone shows me a graph of 429 responses and asks how to make them go away. The honest answer — that nobody likes — is that &lt;strong&gt;the 429s are the well-behaved part of the system&lt;/strong&gt;. The rest is what's broken.&lt;/p&gt;

&lt;p&gt;I'm going to argue that rate limits are not your enemy. They're a contract. And scrapers that treat them like a contract — instead of an obstacle — are the only ones I trust to run unsupervised for more than a quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The teardown
&lt;/h2&gt;

&lt;p&gt;Three things teams typically do when they hit rate limits, in order of how bad they are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add proxies.&lt;/strong&gt; "If they limit &lt;em&gt;me&lt;/em&gt;, I'll just &lt;em&gt;be more people&lt;/em&gt;." This works for about six weeks. Then the target site fingerprints your residential proxy pool and you're back to where you started, with a higher monthly bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decrease delays.&lt;/strong&gt; "If we go faster, we'll finish before they notice." Faster only matters if the request budget exists. Going faster against a hard limit just stacks failures earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry harder.&lt;/strong&gt; Add exponential backoff with a 30-minute cap. Now your "1-hour scraper" is a 4-hour scraper that completes when the throttle window expires.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are forms of the same denial: refusing to accept that the source site is telling you the rate at which they're willing to serve you data. They are. You should listen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What rate limits actually are
&lt;/h2&gt;

&lt;p&gt;A rate limit is the source-site engineer's way of saying: &lt;em&gt;here is the contract under which my system stays healthy&lt;/em&gt;. They published the rate (often: in headers) because they've measured what their infrastructure can serve before things degrade. When you exceed it, you don't just hurt yourself — you contribute to the conditions that get scrapers blocked entirely.&lt;/p&gt;

&lt;p&gt;There are three signals you should be reading from every response, not just the body:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; header.&lt;/strong&gt; This is the source telling you, in seconds, when it'll talk to you again. Respect it literally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; (or equivalent).&lt;/strong&gt; Some sites publish their budget. Use it. Slow down before you hit zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status code distribution over time.&lt;/strong&gt; If your 200 rate is dropping while 429 rises, you're approaching a soft limit you can't see. Back off proactively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're not reading those, your scraper is operating blind against an opponent who is leaving the lights on for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replacement pattern
&lt;/h2&gt;

&lt;p&gt;Here's the rate-aware request loop I drop into every actor:&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RateBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Token bucket — refills at `rate` per second, max `burst` tokens.&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;burst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;burst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;burst&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;burst&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_refill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;burst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_refill&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_refill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&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;budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;Retry-After&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;60&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&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;resp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this does that "decrease the delay" doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token bucket means the rate is global, not per-request.&lt;/strong&gt; Concurrency works without exceeding the contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; is honoured literally.&lt;/strong&gt; No exponential backoff guessing — the source already told you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No proxy rotation.&lt;/strong&gt; You don't need to be more people. You need to be one &lt;em&gt;well-behaved&lt;/em&gt; person.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fufxyhfiqg2szelf6c9a3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fufxyhfiqg2szelf6c9a3.png" alt="Fig. 2 — Aggressive vs polite request rate over time on the same workload. Same code, different contract with the source." width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the two scrapers I migrated to this pattern this quarter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idealista.&lt;/strong&gt; 429 rate dropped from 8% to 0.4%. Total run time went &lt;em&gt;up&lt;/em&gt; by 11% (from 47min to 52min average) — because we stopped hammering. Per-run cost went &lt;em&gt;down&lt;/em&gt; 38% — because we stopped paying for retries that were never going to succeed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sephora.&lt;/strong&gt; 429 rate from 15% to &amp;lt;1%. Run time about the same. Block rate (full IP block requiring rotation) went from "monthly" to "zero in the last 90 days." This one's the real win — we used to burn a residential proxy pool subscription. Now we don't need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern that emerges every time: &lt;strong&gt;respecting the rate makes you slower per-request, but more reliable per-run, and significantly cheaper per-result.&lt;/strong&gt; The unit economics of a polite scraper beat the unit economics of an aggressive one. By a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's wrong
&lt;/h2&gt;

&lt;p&gt;This is wrong if the source site doesn't publish a contract — no &lt;code&gt;Retry-After&lt;/code&gt;, no rate header, just blanket blocks. There you genuinely are guessing. But the guess should still bias toward "much slower than you think you need to be," not toward "more proxies." A token bucket at 1 req/sec is a fine starting point for an unknown site; you can ratchet up while watching error rates.&lt;/p&gt;

&lt;p&gt;This is also wrong if you have explicit business permission to scrape at higher rates — a partnership, an API key, a contract. Those are different relationships. The advice here is for scrapers running against the public web, where 429 is the only contract you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Stop thinking of rate limits as the cost of doing business. Start thinking of them as a free service the target site is providing you: telling you exactly how to stay welcome. Most blocked scrapers I see were blocked not because they "got caught" — they were blocked because they ignored repeated, clearly-articulated signals that they were being rude.&lt;/p&gt;

&lt;p&gt;We packaged the token bucket + &lt;code&gt;Retry-After&lt;/code&gt; honour into a small middleware that sits in front of every actor we ship — visible across our &lt;a href="https://apify.com/sian.agency?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=rate-limits-are-a-feature" rel="noopener noreferrer"&gt;Apify portfolio&lt;/a&gt;. About 30 lines of code. It's the most boring reliability win I've shipped this year, and the most consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which response header is your scraper currently ignoring?&lt;/strong&gt; Drop it in the comments — I'll show you what to do with it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Jonas Keller&lt;/em&gt;&lt;em&gt;, Senior Automation Architect at SIÁN Agency. Find more from Jonas on &lt;a href="https://dev.to/sian-agency"&gt;dev.to&lt;/a&gt;. For custom scraping or automation work, &lt;a href="https://sian.agency?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=rate-limits-are-a-feature" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Instagram Reel Transcripts in 5 Lines — and Word-Level Timestamps Are Free</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Sat, 02 May 2026 04:54:03 +0000</pubDate>
      <link>https://forem.com/sian-agency/instagram-reel-transcripts-in-5-lines-and-word-level-timestamps-are-free-3d7a</link>
      <guid>https://forem.com/sian-agency/instagram-reel-transcripts-in-5-lines-and-word-level-timestamps-are-free-3d7a</guid>
      <description>&lt;p&gt;If you've ever priced Instagram transcription at scale, you already know the trap: per-video pricing on the SaaS tier, plus an upcharge for word-level timestamps. Run the math on 500 reels and you'll close the tab.&lt;/p&gt;

&lt;p&gt;I'm not going to talk you out of building your own pipeline. I'm just going to show you the five lines I run when I don't want to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: per-URL pricing on transcript metadata
&lt;/h2&gt;

&lt;p&gt;Most Instagram transcription APIs in 2026 charge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A base rate per processed video.&lt;/li&gt;
&lt;li&gt;Sometimes a separate rate per minute of audio.&lt;/li&gt;
&lt;li&gt;An &lt;em&gt;additional&lt;/em&gt; fee to expose word-level timestamps (the thing you actually need if you're building captions, search, or any kind of clip editor).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That works for a single creator's library. It does not work for an agency processing client A's 200 reels, then client B's 1,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-line replacement
&lt;/h2&gt;



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

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_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;sian.agency/instagram-ai-transcript-unlimited&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;bulkUrls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.instagram.com/reel/DG06PnPT9aT/&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;wordLevelTimestamps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three input fields you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;instagramUrl&lt;/code&gt; (string) — single reel or video post. Pattern enforced; &lt;code&gt;/reels/&lt;/code&gt; auto-corrects to &lt;code&gt;/reel/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bulkUrls&lt;/code&gt; (array) — paste 1, paste 1,000. Bulk edit, .txt upload, manual list. Same input shape regardless of volume.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wordLevelTimestamps&lt;/code&gt; (boolean, default &lt;code&gt;true&lt;/code&gt;) — get a per-word timestamp on every transcript. &lt;strong&gt;Free.&lt;/strong&gt; You don't pay extra for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third one is the point of this post. It's on by default. Most tools hide it behind a paywall. This one doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can't transcribe
&lt;/h2&gt;

&lt;p&gt;Be honest about the constraints up front:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Image carousels&lt;/strong&gt; — no audio, nothing to transcribe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Music-only videos&lt;/strong&gt; — no spoken audio, the transcript will be empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private profiles&lt;/strong&gt; — Instagram blocks scraping public-side, so the actor only handles public reels and posts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building a "scrape any Instagram URL" feature, you'll hit those edges. The actor returns a clear error per URL — handle it client-side and skip silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "unlimited" is a real claim, not marketing
&lt;/h2&gt;

&lt;p&gt;The actor doesn't charge per validated URL. It charges for compute time per run. If you're processing 1,000 reels in one batch, that's one run. The pricing model rewards batching, which is what you want anyway — bulk is faster than serial because the runtime queue stays warm.&lt;/p&gt;

&lt;p&gt;I migrated an agency client's Instagram audit workflow last week. Old setup: a per-video API at $0.05 + $0.02 word-timestamp upcharge — $35 for 500 reels per audit. New setup: one bulk run, predictable monthly compute. Roughly 1/4 the cost at their volume, and the dataset shape is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;If you want to see what 30+ data points + word-level transcripts look like for your own client list, run it once: &lt;a href="https://apify.com/sian.agency/instagram-ai-transcript-unlimited?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=instagram-reel-transcripts-word-timestamps" rel="noopener noreferrer"&gt;Instagram AI Transcript Unlimited&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Single-URL test costs less than a coffee. Bulk run is unlimited.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tell me where this breaks.&lt;/strong&gt; If you've found a public reel format the URL pattern misses, drop it in the comments. I'll get the maintainer to ship a fix in the next build.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Nova Chen&lt;/em&gt;&lt;em&gt;, Automation Dev Advocate at SIÁN Agency. Find more from Nova on &lt;a href="https://dev.to/sian-agency"&gt;dev.to&lt;/a&gt;. For custom scraping or automation work, &lt;a href="https://sian.agency?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=instagram-reel-transcripts-word-timestamps" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>socialmedia</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Stopped Writing TikTok Scrapers. Five Lines of Python Replaced Them.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Mon, 27 Apr 2026 13:34:57 +0000</pubDate>
      <link>https://forem.com/sian-agency/i-stopped-writing-tiktok-scrapers-five-lines-of-python-replaced-them-5824</link>
      <guid>https://forem.com/sian-agency/i-stopped-writing-tiktok-scrapers-five-lines-of-python-replaced-them-5824</guid>
      <description>&lt;p&gt;If your TikTok scraper still uses Playwright + custom selectors, this post will annoy you. Good. Read it anyway.&lt;/p&gt;

&lt;p&gt;I burned three weekends last quarter on a "minimal" TikTok scraper. Selector-first, headless, the works. Worked beautifully for nine days. Then TikTok shipped a layout change at 2am UTC and my fixtures became fiction.&lt;/p&gt;

&lt;p&gt;The honest answer most devs avoid: &lt;strong&gt;for known platforms with stable APIs around them, you should not be writing the scraper.&lt;/strong&gt; You should be calling someone's actor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop owning the layer that breaks
&lt;/h2&gt;

&lt;p&gt;Three things break a TikTok scraper, and none of them are about your code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layout drift.&lt;/strong&gt; Selectors are a liability the second TikTok touches the DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth + rate-limit games.&lt;/strong&gt; Cloudflare, fingerprinting, the whole party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio extraction + transcription.&lt;/strong&gt; Even if you got the video, now you need Whisper, ffmpeg, a queue, and a dead body to bury when it OOMs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You're not getting paid to maintain that. You're getting paid to ship the thing on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced 800 lines of Python for me
&lt;/h2&gt;



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

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_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;sian.agency/best-tiktok-ai-transcript-extractor&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;bulkUrls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.tiktok.com/@user/video/7565659068153531669&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Five lines. The actor's input schema has exactly two fields you need to know about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tiktokUrl&lt;/code&gt; (string) — single video. Pass any URL format. Short links from &lt;code&gt;vm.tiktok.com&lt;/code&gt; get resolved. Mobile share URLs work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bulkUrls&lt;/code&gt; (array) — paste 5, 50, or 500. Bulk edit, file upload, line-separated, comma-separated. It doesn't care.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire input surface. Two keys. No proxy config, no captcha settings, no "headless or headful" debate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get back
&lt;/h2&gt;

&lt;p&gt;Per video, you get the AI transcript (99%+ accuracy claimed by the actor — empirically I see ~98% on English, lower on heavy slang) plus 45 metadata fields: views, likes, shares, creator stats, hashtags, music ID, location, content categories. The transcript ships with detected language and segment timing, so you can search inside videos like text.&lt;/p&gt;

&lt;p&gt;I rewrote a competitor-monitoring pipeline last month using this. Old stack: Playwright cluster + Whisper container + Redis + a cron + a Slack channel where I apologized weekly. New stack: a 60-line Python script and the actor. Same dataset, less surface area, no apologies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The objection I keep getting
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why pay per run when I can self-host?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because your time isn't free, and you don't actually self-host — you self-rebuild every two weeks when something shifts. The actor charges per validated result. You only pay for the runs that gave you usable data. That's a different cost model than "compute hours your worker spent crashing."&lt;/p&gt;

&lt;p&gt;If your volume is genuinely huge, sure, build it. But "huge" is an engineering decision, not a default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own URL
&lt;/h2&gt;

&lt;p&gt;The free tier handles 5 videos per run, 8s delay between them. If you want to see the dataset shape for your own use case, drop a TikTok URL in and watch it run: &lt;a href="https://apify.com/sian.agency/best-tiktok-ai-transcript-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=tiktok-transcripts-5-lines-python" rel="noopener noreferrer"&gt;TikTok AI Transcript Extractor on Apify&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Bulk mode is paid — unlimited per run, no delays, no per-video charges. Use it when you're past the experiment phase.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disagree?&lt;/strong&gt; Drop the snippet you're using to scrape TikTok in the comments. I'll tell you which line is going to break first. Be specific — "I use Puppeteer" is not a snippet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Nova Chen&lt;/em&gt;&lt;em&gt;, Automation Dev Advocate at SIÁN Agency. Find more from Nova on &lt;a href="https://dev.to/sian-agency"&gt;dev.to&lt;/a&gt;. For custom scraping or automation work, &lt;a href="https://sian.agency?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=tiktok-transcripts-5-lines-python" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
