<?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: SIÁN Agency</title>
    <description>The latest articles on DEV Community by SIÁN Agency (@sian-agency).</description>
    <link>https://dev.to/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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3854792%2Fcb57fd08-1d47-4084-97aa-8c4879d72af0.png</url>
      <title>DEV Community: SIÁN Agency</title>
      <link>https://dev.to/sian-agency</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sian-agency"/>
    <language>en</language>
    <item>
      <title>Someone Ran My Scraper 1,251 Times and Paid Me Nothing</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Mon, 06 Jul 2026 15:10:15 +0000</pubDate>
      <link>https://dev.to/sian-agency/someone-ran-my-scraper-1251-times-and-paid-me-nothing-58m0</link>
      <guid>https://dev.to/sian-agency/someone-ran-my-scraper-1251-times-and-paid-me-nothing-58m0</guid>
      <description>&lt;p&gt;Someone ran one of my public actors &lt;strong&gt;1,251 times in four days.&lt;/strong&gt; I earned roughly enough to cover a sandwich.&lt;/p&gt;

&lt;p&gt;If your first instinct is "billing bug," you're going to lose this game. Mine wasn't broken. The runs were real, the results were real, and every single one was engineered to pay me nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spike you'll mistake for success
&lt;/h2&gt;

&lt;p&gt;Here's what the dashboard showed: a wall of runs. More traffic in four days than the actor saw the entire previous month. Glance at the top-line chart and that's a growth story. You screenshot it. You feel good.&lt;/p&gt;

&lt;p&gt;Then you look at &lt;em&gt;who&lt;/em&gt;. In four days: &lt;strong&gt;1,251 runs across 761 different accounts.&lt;/strong&gt; Almost all on the free plan. Every request through the API, none through the UI. Around 30,000 results pulled.&lt;/p&gt;

&lt;p&gt;That same actor had &lt;strong&gt;13 real users the entire previous month.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Free traffic is not free to you
&lt;/h2&gt;

&lt;p&gt;This is where the mental model breaks. A free-tier user isn't a marketing cost you eat for goodwill. On a scraper, every run I serve calls a paid data API upstream, one I pay for per request. So 30,000 free results is 30,000 units of &lt;em&gt;my&lt;/em&gt; quota, spent, for a rounding error in revenue.&lt;/p&gt;

&lt;p&gt;The attacker pays nothing. I pay the upstream. That's the entire play.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-fingerprint tell
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmplmlm31ktv3gmwce05k.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmplmlm31ktv3gmwce05k.png" alt="Funnel: 761 free-tier accounts collapse to 2 session fingerprints, and one operator — 1,251 runs, ~30,000 results, $0 revenue, your paid API quota drained." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;761 accounts sounds like 761 people. It wasn't. When I grouped the runs by session fingerprint, all 1,251 collapsed into &lt;strong&gt;two&lt;/strong&gt;. One fingerprint owned 1,050 runs, the other 183.&lt;/p&gt;

&lt;p&gt;That's not 761 users. That's one operator running a script that mints throwaway accounts. The account count is noise. The fingerprint count is the truth. The moment you see two, you stop investigating and start defending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your billing looked fine the whole time
&lt;/h2&gt;

&lt;p&gt;The trap: I checked the money first. Charged events matched the platform's own numbers to the cent. Everything reconciled. So for an hour I assumed the system was working as intended — because &lt;em&gt;technically it was.&lt;/em&gt; Billing was flawless. The abuse lived one layer up, in who was allowed to trigger a paid run at all.&lt;/p&gt;

&lt;p&gt;Reconciling your billing tells you nothing about whether you should have run the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brake I should have shipped first
&lt;/h2&gt;

&lt;p&gt;The fix is small and boring, which is how you know it's right. Gate the expensive work behind a paid-plan check that fails &lt;em&gt;before&lt;/em&gt; the first upstream call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reject free-tier runs before we spend a cent of upstream quota.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPaying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;onPlatformPaidFlag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;        &lt;span class="c1"&gt;// a real paying user on the platform&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOnPlatform&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;testOverride&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// local/dev smoke test only&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPaying&lt;/span&gt;&lt;span class="p"&gt;)&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Paid plan required — no upstream call, no charge.&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="c1"&gt;// ...only paying runs reach the expensive part&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast-failing here means the farm gets an immediate error and burns zero quota. It's reversible: one block, deleted the day the farm gives up. No refactor, no rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one gotcha before you copy this
&lt;/h2&gt;

&lt;p&gt;Don't gate blindly. The platform runs its own automated test of your public actor on a &lt;em&gt;non-paying&lt;/em&gt; run to keep it visible in the store. Reject that too and your listing quietly goes empty and sinks in the rankings. Whitelist the platform's own test origin, then throw on everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do this today
&lt;/h2&gt;

&lt;p&gt;Ship the brake before you need the forensics. I did it in the wrong order so you don't have to.&lt;/p&gt;

&lt;p&gt;If you run public actors, go look at your last big traffic spike right now. Group it by fingerprint, not by account. Tell me what you find in the comments. And if you'd rather your scrapers shipped with the brake already wired in, that's the kind of thing &lt;a href="https://apify.com/sian.agency?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=free-tier-farm-emergency-brake" rel="noopener noreferrer"&gt;we build at SIÁN Agency&lt;/a&gt;.&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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>apify</category>
      <category>automation</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Replayable Runs &gt; Faster Runs. Stop Optimising for the Wrong Number.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Mon, 06 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/replayable-runs-faster-runs-stop-optimising-for-the-wrong-number-3ob0</link>
      <guid>https://dev.to/sian-agency/replayable-runs-faster-runs-stop-optimising-for-the-wrong-number-3ob0</guid>
      <description>&lt;p&gt;Most "we made it 3x faster" scraper posts miss the actual point. Speed is rarely the constraint. &lt;em&gt;Replayability&lt;/em&gt; is.&lt;/p&gt;

&lt;p&gt;If your scraper takes 4 hours to run and a single URL fails halfway through, can you re-run &lt;em&gt;just that URL&lt;/em&gt; in 30 seconds? Or do you have to start the whole 4-hour job over?&lt;/p&gt;

&lt;p&gt;If the answer is "start over," you don't have a scraper. You have a long-running prayer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item checklist
&lt;/h2&gt;

&lt;p&gt;A replayable run looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inputs are explicit and persisted.&lt;/strong&gt; Every URL/parameter the run is processing is written to a queue or dataset before it starts. You can re-read the input list later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outputs are addressable per input.&lt;/strong&gt; You can ask "did URL X succeed?" and get a yes/no, not "well, the run finished, so probably."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are first-class records.&lt;/strong&gt; Failed inputs go to a separate dataset/queue with the error reason, ready to feed back into a retry run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When all three hold, "rerun the failures" is a one-liner. When any of them is missing, recovery is manual archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick — input/output as separate datasets
&lt;/h2&gt;

&lt;p&gt;Here's the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify.storages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RequestQueue&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&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;input_data&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;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_input&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Push inputs into a queue. Idempotent — re-runs skip already-done items.
&lt;/span&gt;&lt;span class="n"&gt;queue&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;RequestQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;podcast-urls-2026-06&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;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_request&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;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;uniqueKey&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="c1"&gt;# 2. Process the queue, splitting outputs into success and failure datasets.
&lt;/span&gt;&lt;span class="n"&gt;results&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;Dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;podcast-results-2026-06&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;failures&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;Dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;podcast-failures-2026-06&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;:&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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_next_request&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;await&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_request_as_handled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_data&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;request&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_request_as_handled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# don't retry blindly
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three storages: input queue, success dataset, failure dataset. The queue is keyed by URL, so adding the same URLs again is a no-op. The failure dataset is the input for the next retry run.&lt;/p&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%2Fypztx3ee1u0fm581ybxe.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%2Fypztx3ee1u0fm581ybxe.png" alt="Fig. 1 — Three storages, one run. Failures are data, not exceptions." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick case
&lt;/h2&gt;

&lt;p&gt;The podcast transcription actor used to be a 6-hour batch job. When a single episode failed (audio download timeout, transcription model glitch, anything), the recovery story was: "find the failed URL in the logs, hand-craft a one-URL run, hope the second try works."&lt;/p&gt;

&lt;p&gt;After moving to the queue + split-dataset pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Failed URLs are visible in a dedicated dataset, with the error and timestamp.&lt;/li&gt;
&lt;li&gt;"Retry yesterday's failures" is one button: open the failures dataset, push its rows into a new run as input.&lt;/li&gt;
&lt;li&gt;The original run's success dataset doesn't get re-processed — it just gets &lt;em&gt;appended to&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What used to take 30 minutes of manual triage is now a 30-second action. Same scraper, same selectors, same model — different runtime structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;The queue + success-dataset + failure-dataset pattern is the third thing every actor we ship gets, after request blocking and selector ladder — visible in the &lt;a href="https://apify.com/sian.agency/transcribe-podcast-to-text?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=replayable-runs-failure-datasets" rel="noopener noreferrer"&gt;podcast transcription actor&lt;/a&gt;. (We have a starter template now. Same shape every time.)&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open your scraper. If a single URL fails, what does recovery look like?&lt;/strong&gt; If your answer takes more than one paragraph, drop it in the comments — I'll show you the smaller version.&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a recovery story that doesn't need this? Reply.&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=replayable-runs-failure-datasets" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>productivity</category>
      <category>systemdesign</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>I Rewrote Our Instagram Transcript Actor for Pay-Per-Event Pricing. The Economics Flipped.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Thu, 25 Jun 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/i-rewrote-our-instagram-transcript-actor-for-pay-per-event-pricing-the-economics-flipped-154d</link>
      <guid>https://dev.to/sian-agency/i-rewrote-our-instagram-transcript-actor-for-pay-per-event-pricing-the-economics-flipped-154d</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Moved an Instagram transcript actor from pay-per-result to pay-per-event billing. Three events, not one number. Margin held, retries stopped silently bleeding cash, and the actor is now honest about what it's actually charging for. If your scraper has a "credits" tab in the README, this is for you.&lt;/p&gt;

&lt;p&gt;For a year I shipped scrapers the same way everyone does: one big knob — pay-per-result, $X per item, computed at the end of the run. It looked clean from the README. It was a mess underneath.&lt;/p&gt;

&lt;p&gt;The actor would start, spin up a browser, hit Instagram, run into a transient block, retry, succeed on three out of ten URLs, and spit back a number. The user paid for three. We absorbed the cost of the seven retries, the cold start, and the GPU minutes the transcription model burned on partial audio. On a good week the unit economics worked. On a bad week — when Instagram changed something and our success rate dropped to 60% — we paid Apify and OpenAI for the privilege of running a free service.&lt;/p&gt;

&lt;p&gt;That's the trap pay-per-result puts you in. Your price is fixed. Your cost isn't.&lt;/p&gt;

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

&lt;p&gt;Pay-per-result conflates three different things into one transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Setup work&lt;/strong&gt; — booting the actor, validating input, warming the browser. Happens once per run regardless of how many URLs you pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-item work&lt;/strong&gt; — fetching the post, extracting media, calling the transcription model. Scales linearly with input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional premium work&lt;/strong&gt; — the fast-processing path that costs us more per item but the user explicitly asked for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Charging one rate for "a result" forces you to subsidise items #1 and #3 out of the margin on item #2. When users bulk-submit URLs, item #1 amortises and you're fine. When they submit one URL at a time, you eat the setup cost on every run. When they enable fast processing on every call, you eat the premium delta on every call.&lt;/p&gt;

&lt;p&gt;Apify's pay-per-event model lets you charge for each of these separately. So we did.&lt;/p&gt;

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

&lt;p&gt;The new actor declares three events in &lt;code&gt;actor.json&lt;/code&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="nl"&gt;"monetization"&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;"events"&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;"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;"ActorRunStarted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.005&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;"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;"InstagramContentProcessed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.018&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;"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;"FastProcessingUpgrade"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.002&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;Then in the actor body, you charge against those events at the moment the work is actually done:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apify&lt;/span&gt;&lt;span class="dl"&gt;'&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;init&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;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ActorRunStarted&lt;/span&gt;&lt;span class="dl"&gt;'&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;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bulkUrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processInstagramPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fastProcessing&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;Dataset&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;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Only charge per item on success.&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;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;InstagramContentProcessed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fastProcessing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FastProcessingUpgrade&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Failed items don't bill the user. They also don't bleed margin&lt;/span&gt;
    &lt;span class="c1"&gt;// because the run-started fee already covered the setup.&lt;/span&gt;
    &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Skipping &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run starts always bill.&lt;/strong&gt; $0.005 covers boot. Doesn't matter if zero items succeed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-item billing only fires after &lt;code&gt;pushData&lt;/code&gt;.&lt;/strong&gt; Failures are free for the user — and free of margin loss for us, because we already covered fixed cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium path bills on top.&lt;/strong&gt; If the user opted into fast processing, that delta is charged separately and visibly.&lt;/li&gt;
&lt;/ul&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%2Fcf9p23135rzkmd3be2zn.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%2Fcf9p23135rzkmd3be2zn.png" alt="Fig. 1 — Three billing events per run. Setup, per-item, and optional premium are charged separately." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Three months in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Margin per run &lt;strong&gt;stopped going negative&lt;/strong&gt; on small-batch / high-failure runs. The run-started fee acts as a floor.&lt;/li&gt;
&lt;li&gt;Failed-URL ratio dropped from 12% to 4% — not because we got better, but because we stopped &lt;em&gt;hiding&lt;/em&gt; failures behind a flat result fee. Users started reporting bad URLs in support, instead of opening refund tickets.&lt;/li&gt;
&lt;li&gt;Average revenue per user went &lt;strong&gt;up&lt;/strong&gt;, not down, even though our headline price ($0.018/item) was lower than the previous flat $0.025. Setup fee + opt-in premium fee made up the difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cleaner pricing, cleaner margin, cleaner conversation with users about what they're actually paying for.&lt;/p&gt;

&lt;p&gt;If you're running an Apify actor on flat pay-per-result and your retry rate is anything above noise, you're subsidising the unreliable part of your stack. Move the line. Charge for what you do, not for what survives. The Instagram actor I rewrote with this model is live at &lt;a href="https://apify.com/sian.agency/instagram-ai-transcript-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=pay-per-event-pricing-instagram-actor" rel="noopener noreferrer"&gt;Instagram AI Transcript Extractor&lt;/a&gt; — same shape applied across the rest of our actor portfolio over the last quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What event are you not charging for that you should be?&lt;/strong&gt; Drop the actor in the comments — I'll look at the schema.&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=pay-per-event-pricing-instagram-actor" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>saas</category>
      <category>softwareengineering</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Migration Playbook: Cron Script Actor. Six Steps, No Rewrites.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/migration-playbook-cron-script-actor-six-steps-no-rewrites-38o6</link>
      <guid>https://dev.to/sian-agency/migration-playbook-cron-script-actor-six-steps-no-rewrites-38o6</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Migrating a long-running cron-based scraper to an actor architecture does not require a rewrite. It requires &lt;em&gt;six structural changes&lt;/em&gt; applied in order. Each one is independently shippable. Each one moves the scraper closer to a state where infrastructure is no longer your problem. We migrated our interview transcription pipeline this way over four iterations. Here's the order I'd run it again.&lt;/p&gt;

&lt;p&gt;I've watched too many migrations fail because someone said "let's rewrite this as an actor" and treated it as a from-scratch project. They burn a sprint, miss edge cases the original handled, ship something that works in dev and breaks under real input, and end up reverting.&lt;/p&gt;

&lt;p&gt;The pipeline doesn't need a rewrite. It needs surgery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six-step migration order
&lt;/h2&gt;

&lt;p&gt;Each step is a PR. Each step ships independently. The cron job keeps running until step 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Extract input from the script body
&lt;/h3&gt;

&lt;p&gt;Find the hardcoded list of URLs / config values / paths in your script. Move them to a JSON config file. The script reads from the config; the config is parameterised.&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;# Before:
&lt;/span&gt;&lt;span class="n"&gt;URLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://...&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;https://...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;OUTPUT_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/data/output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# After:
&lt;/span&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;sys&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&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;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron now does &lt;code&gt;cat config.json | python script.py&lt;/code&gt; instead of &lt;code&gt;python script.py&lt;/code&gt;. Behaviour identical. Surface area changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why first&lt;/strong&gt;: every later step depends on having a typed input. Doing this first means everything that follows operates on the same shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Replace ad-hoc output with a structured dataset
&lt;/h3&gt;

&lt;p&gt;Instead of writing rows directly to a CSV, push them to a function that wraps the persistence layer:&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;push_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Today, this writes to a CSV.
&lt;/span&gt;    &lt;span class="nf"&gt;write_csv_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Tomorrow, this writes to Apify Dataset, S3, BigQuery, whatever.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data shape, abstracted writer. The cron still produces a CSV. Step 6 swaps the writer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why second&lt;/strong&gt;: schema changes are easier when there's one place that knows about the shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Replace bare &lt;code&gt;try/except&lt;/code&gt; with structured failures
&lt;/h3&gt;

&lt;p&gt;Audit every &lt;code&gt;try/except&lt;/code&gt;. If it swallows the exception, replace with explicit logging and a failure record:&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;# Before:
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;process&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="c1"&gt;# After:
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;process&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;push_failure&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;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;error&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;e&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="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now failures are first-class data. Same rows of work; the bad ones go to a different file (or dataset) instead of vanishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why third&lt;/strong&gt;: this is the step where you stop losing data silently. Every later step assumes failures are visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Replace &lt;code&gt;print()&lt;/code&gt; with structured logging
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before:
&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;Processing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&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="c1"&gt;# After:
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transcribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&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;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;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;stage&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;transcribe&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;Use a logging library that supports structured fields. (Python: &lt;code&gt;structlog&lt;/code&gt;, &lt;code&gt;loguru&lt;/code&gt;, or &lt;code&gt;logging&lt;/code&gt; with a JSON formatter.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why fourth&lt;/strong&gt;: logs are what step 6 will be reading. They need shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Containerise
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.11-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "script.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron now runs &lt;code&gt;docker run my-scraper&lt;/code&gt;. Same input/output. Containerised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why fifth&lt;/strong&gt;: containerisation is portable. Step 6 needs it; nothing earlier did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6 — Swap the runtime
&lt;/h3&gt;

&lt;p&gt;Now you point the container at an actor runtime — Apify, Kubernetes CronJob, Cloud Run, whatever. The container is the same. The cron entry is gone. Scheduling, retries, logging, persistence are now provided by the runtime.&lt;/p&gt;

&lt;p&gt;This is the step that takes a &lt;em&gt;day&lt;/em&gt;. Steps 1–5 might take 2–3 days each. The point of doing them first is that step 6, the one most teams treat as the whole migration, is small and reversible by the time you reach it.&lt;/p&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%2Fnemaptm6kx4euy8hfbfm.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%2Fnemaptm6kx4euy8hfbfm.png" alt="Fig. 1 — The six-step migration order. Each step ships independently. Step 6 is the smallest." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this order
&lt;/h2&gt;

&lt;p&gt;Each step is &lt;strong&gt;independently valuable&lt;/strong&gt; even if you stop. After step 1 you have a parameterised script — useful for ad-hoc runs. After step 2 you can change persistence. After step 3 you stop losing data. After step 4 you can debug remotely. After step 5 you can deploy anywhere. After step 6 you have an actor.&lt;/p&gt;

&lt;p&gt;If a stakeholder asks why the rewrite is taking so long, you can point at the running improvements at any step. There is no "we're 60% done with the rewrite, it's not running yet" phase.&lt;/p&gt;

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

&lt;p&gt;The interview transcription actor went through this migration over four months, one step at a time, while running in production the entire time. Pre-migration: ad-hoc cron, 18% silent-failure rate, mean time to detect issues ~24 hours. Post-migration: actor with retries and structured logging, 1.2% failure rate (and the failures are &lt;em&gt;visible&lt;/em&gt;), mean time to detect &amp;lt;30 minutes.&lt;/p&gt;

&lt;p&gt;Total team-hours: roughly 60. Spread across four iterations. Compare to the rewrites I've seen go sideways: typically 80–120 hours and a stalled cutover.&lt;/p&gt;

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

&lt;p&gt;Two cases where a rewrite genuinely beats the migration playbook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The original script is &lt;em&gt;very&lt;/em&gt; small (under 100 lines, single function). At that scale, the migration steps cost as much as a rewrite, and the rewrite gives you a cleaner result.&lt;/li&gt;
&lt;li&gt;The original is in a language your team doesn't maintain (a Perl script you inherited, a Bash pipeline). At some point the cost of step 1 alone exceeds the rewrite cost.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Otherwise: surgery, not rewrite. We packaged this six-step migration as a checklist we apply to every legacy scraper an engagement starts with — same shape we used to rebuild the &lt;a href="https://apify.com/sian.agency/transcribe-interview-to-text?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=cron-to-actor-migration-six-steps" rel="noopener noreferrer"&gt;interview transcription actor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where in the six steps is your current scraper?&lt;/strong&gt; Drop the answer — I'll point at the next change that buys you the most.&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=cron-to-actor-migration-six-steps" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>I Broke This Scraper on Purpose. Here's What Shipped to Production Unprotected.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/i-broke-this-scraper-on-purpose-heres-what-shipped-to-production-unprotected-3bl2</link>
      <guid>https://dev.to/sian-agency/i-broke-this-scraper-on-purpose-heres-what-shipped-to-production-unprotected-3bl2</guid>
      <description>&lt;p&gt;Best way to find out where your scraper is fragile? Break it. On purpose. In a controlled way, in a test environment, with a checklist of failure modes you actively &lt;em&gt;try&lt;/em&gt; to inject.&lt;/p&gt;

&lt;p&gt;This is chaos engineering for scrapers. Most teams don't do it because they're convinced their scraper "works." Then they discover what doesn't work the hard way, in production, on a Sunday.&lt;/p&gt;

&lt;p&gt;I ran the exercise on our image metadata scraper last week. Here's what I broke and what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item attack list
&lt;/h2&gt;

&lt;p&gt;Three categories of injected failure that catch most fragility:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network failure&lt;/strong&gt; — slow responses, dropped connections, partial bodies, 5xx responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content failure&lt;/strong&gt; — malformed HTML, missing fields, unexpected types (string where number was expected).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial input&lt;/strong&gt; — empty inputs, very large inputs, URLs that 404, URLs that redirect to login pages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your scraper survives all three, you have a real scraper. If it crashes or hangs on any of them, you've found a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick — Playwright route handlers as fault injectors
&lt;/h2&gt;

&lt;p&gt;Playwright's request routing isn't just for blocking ads. It's a controlled chaos primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inject a 30% rate of 503 responses&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Service Unavailable&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Inject latency&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/api/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Inject malformed JSON&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/metadata.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;contentType&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/json&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"title": "Test", "size": &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// truncated JSON&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;Now run your scraper. See what falls over.&lt;/p&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%2Fujuu14swdknycxou3jty.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%2Fujuu14swdknycxou3jty.png" alt="Fig. 1 — Five injected attacks, five real bugs. 90 minutes, no production traffic needed." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke when I did this last week
&lt;/h2&gt;

&lt;p&gt;Image metadata scraper, running against a fixture set of 100 URLs with the failure handlers above wired in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;503 injection at 30%&lt;/strong&gt; → scraper hung on a single URL for 90 seconds before failing. Found: missing per-request timeout. Fix: 15-second hard timeout per page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5-second latency injection&lt;/strong&gt; → scraper completed but reported 0 results for affected URLs. Found: &lt;code&gt;wait_for_selector&lt;/code&gt; had an implicit 5-second timeout that exactly matched the injected latency, so it failed silently. Fix: explicit timeout, longer than expected p99 page load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Truncated JSON injection&lt;/strong&gt; → uncaught &lt;code&gt;JSONDecodeError&lt;/code&gt;, killed the entire run. Found: no try/except around the JSON parser. Fix: wrap in &lt;code&gt;try/except&lt;/code&gt;, push to failures dataset (per last week's post).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty input array&lt;/strong&gt; → scraper exited with code 0 and an empty dataset. Found: no validation of input shape. Fix: assert &lt;code&gt;len(input.urls) &amp;gt; 0&lt;/code&gt; at start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404 URLs (mixed in with valid URLs)&lt;/strong&gt; → scraper retried each three times before giving up, doubling run time. Found: 404 was being treated as transient, not permanent. Fix: 404 → push to failures &lt;em&gt;immediately&lt;/em&gt;, no retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five real bugs, found in 90 minutes. Every one of them would have eventually hit production. Two of them already had — the timeout one was the cause of a Slack alarm we got in March that we'd "fixed" by restarting the actor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;We now run a chaos test suite against every actor before it ships. Same five injections every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Random 503s at 30%.&lt;/li&gt;
&lt;li&gt;Random 5s latency at 20%.&lt;/li&gt;
&lt;li&gt;Malformed JSON on the data endpoint.&lt;/li&gt;
&lt;li&gt;Empty input array.&lt;/li&gt;
&lt;li&gt;50% invalid URLs in the input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It takes 5 minutes to run, and it catches things real-traffic testing won't, because real traffic doesn't reliably produce the bad cases. The chaos suite is what caught the timeouts in the &lt;a href="https://apify.com/sian.agency/exif-gps-metadata-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=chaos-testing-scrapers-injection" rel="noopener noreferrer"&gt;image metadata scraper&lt;/a&gt; before its first paying user noticed.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick one of the five injections above. Run it against your scraper today.&lt;/strong&gt; Drop what broke in the comments — I'll guess the failure mode if you give me one detail.&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a chaos test that catches something subtler? Reply.&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=chaos-testing-scrapers-injection" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Schema Drift Is the Silent Killer. Here's What to Log So You Actually Catch It.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Tue, 02 Jun 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/schema-drift-is-the-silent-killer-heres-what-to-log-so-you-actually-catch-it-15dm</link>
      <guid>https://dev.to/sian-agency/schema-drift-is-the-silent-killer-heres-what-to-log-so-you-actually-catch-it-15dm</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Most scraper "bugs" aren't bugs. They're the source site changing its data shape underneath you while your selectors and your code keep returning success. This is schema drift, and you cannot prevent it. You can only detect it. The detection has to be designed in. Here's how we do it.&lt;/p&gt;

&lt;p&gt;I have a low opinion of any scraper that does not log a per-field availability rate. It's the single most useful number you can produce, and almost nobody produces it.&lt;/p&gt;

&lt;p&gt;The premise: every record you scrape has a set of expected fields. After every run, you compute, for each field, the percentage of records that had a non-null value for it. You log that number. You alarm on it.&lt;/p&gt;

&lt;p&gt;That's it. That's the whole technique.&lt;/p&gt;

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

&lt;p&gt;A scraper has three failure modes you actually care about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Total failure&lt;/strong&gt; — the run errors out, you get a stack trace, you fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial failure&lt;/strong&gt; — some URLs fail, you log them, you retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift&lt;/strong&gt; — every URL "succeeds," every record looks fine, but a field has silently gone from 98% present to 30% present.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are loud. The third is silent. Schema drift is what produces "the dashboard looks weird" support tickets a week after the cause.&lt;/p&gt;

&lt;p&gt;Real example, from our Sephora product info actor: in March, the site moved the "ingredients" field from a top-level dropdown into a tab inside a modal. Our existing selector still found &lt;em&gt;something&lt;/em&gt; on the page — a placeholder div — and our code happily wrote &lt;code&gt;ingredients=""&lt;/code&gt; to the dataset. No error, no alarm. The CSV had ingredient column. The values were empty for new products. Detected eight days later by a customer who tried to filter by allergen.&lt;/p&gt;

&lt;p&gt;If we had been logging field availability, we would have seen the ingredient field drop from 96% present to 11% present in a single deploy and caught it inside an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The teardown of why this gets missed
&lt;/h2&gt;

&lt;p&gt;Most scrapers track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rows extracted per run.&lt;/li&gt;
&lt;li&gt;Errors per run.&lt;/li&gt;
&lt;li&gt;Run duration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those move when schema drift happens. The row count is the same. The error rate is zero. The run duration is the same. You have to be looking at field-level data to see it.&lt;/p&gt;

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

&lt;p&gt;After every run, compute and log this:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;field_availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_fields&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns the % of records where each field is non-null.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&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;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expected_fields&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;record&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;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
                &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expected_fields&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the end of the run:&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;availability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field_availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EXPECTED_FIELDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field_availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Alarm on regression vs last run.
&lt;/span&gt;&lt;span class="n"&gt;prev&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;KeyValueStore&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_field_availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pct&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 10-point drop is suspicious
&lt;/span&gt;        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&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;availability regression: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&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;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;field&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;pct&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;KeyValueStore&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_field_availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three log lines per run. Persistent state across runs. An alarm when any field drops more than 10 percentage points.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to monitor specifically
&lt;/h2&gt;

&lt;p&gt;Field availability is the one that catches the most. Two more I find pay for themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Value distribution shift.&lt;/strong&gt; For numeric fields (price, rating, count), log the median and p95. If price suddenly goes from "median ~$30" to "median 0.0" you have a parser bug, not just availability drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selector hit count.&lt;/strong&gt; When you fall back from primary to secondary selector, log it. If your fallback rate goes from 1% to 40%, the primary selector is on its way out — you have a week or so before it goes to zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These three together (availability, distribution, fallback rate) catch ~90% of schema drift before it produces customer-visible bugs.&lt;/p&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%2Fs11vuw7ui4vby9w0yx0a.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%2Fs11vuw7ui4vby9w0yx0a.png" alt="Fig. 1 — Field availability across runs. The drop on day 4 is schema drift, not a bug." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;We added per-field availability logging across the Sephora actor portfolio in February. In the four months since:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;6 schema-drift incidents caught and fixed within 48 hours of the source-site change.&lt;/li&gt;
&lt;li&gt;Mean detection lag went from "a customer noticed" (~7 days) to "the alarm fired" (~12 hours, the gap being our run cadence).&lt;/li&gt;
&lt;li&gt;One incident where the field availability &lt;em&gt;dropped&lt;/em&gt; in a way that &lt;em&gt;was&lt;/em&gt; expected (Sephora removed a field site-wide); we acknowledged and updated the schema. Net cost: 20 minutes, including writing the postmortem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost: about 30 lines of code per actor, run-time overhead measured in milliseconds.&lt;/p&gt;

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

&lt;p&gt;Field availability is a poor signal when your input is inherently heterogeneous. If you're scraping listings where some products have ingredients and most don't, "30% have ingredients" might be normal. The technique still works — you just compare &lt;em&gt;to the previous run&lt;/em&gt;, not to an absolute target. A 10-point drop is the alarm; the absolute number doesn't matter.&lt;/p&gt;

&lt;p&gt;If you're scraping a homogeneous catalogue (every product has a title and a price), absolute thresholds work fine. Title &amp;lt;99% present? Something is wrong.&lt;/p&gt;

&lt;p&gt;We packaged the field-availability + distribution + fallback-rate triple into a small middleware that sits at the end of every actor we ship — first deployed on the &lt;a href="https://apify.com/sian.agency/best-sephora-product-information-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=schema-drift-field-availability" rel="noopener noreferrer"&gt;Sephora product info actor&lt;/a&gt; and rolled out portfolio-wide. Three lines to wire up, alarms in your inbox the day a source site decides to change their schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which of the three signals is missing from your scraper right now?&lt;/strong&gt; Drop it in the comments — I'll show you the smallest version that works.&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=schema-drift-field-availability" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>One Playwright Selector Trick Nobody Talks About: getByRole</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Sun, 31 May 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/one-playwright-selector-trick-nobody-talks-about-getbyrole-100e</link>
      <guid>https://dev.to/sian-agency/one-playwright-selector-trick-nobody-talks-about-getbyrole-100e</guid>
      <description>&lt;p&gt;Everyone reaches for &lt;code&gt;page.locator(".some-class")&lt;/code&gt; first. They shouldn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getByRole&lt;/code&gt; is the most stable selector in Playwright and almost nobody uses it for scraping. They think it's a testing-library thing. It's not. It's a way of asking the page "what is this element semantically" instead of "what classname does the design system happen to use this week."&lt;/p&gt;

&lt;p&gt;That distinction is what kept our Facebook video transcript actor running through three Facebook redesigns this past year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item checklist
&lt;/h2&gt;

&lt;p&gt;When does &lt;code&gt;getByRole&lt;/code&gt; work? When the site is built by people who care about accessibility. Which is: more sites than you think, especially big ones with legal requirements (US government, EU compliance, large e-commerce).&lt;/p&gt;

&lt;p&gt;Check before you skip it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open the accessibility tree&lt;/strong&gt; in Chrome DevTools (Elements → Accessibility tab). If your target element shows a role and an accessible name, &lt;code&gt;getByRole&lt;/code&gt; will find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buttons and headings are nearly always tagged correctly.&lt;/strong&gt; Even sloppy sites give you &lt;code&gt;role="button"&lt;/code&gt; and proper heading levels because the design system enforced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forms expose &lt;code&gt;label&lt;/code&gt; even when the visual design hides it.&lt;/strong&gt; &lt;code&gt;getByLabel("Email")&lt;/code&gt; works on inputs that don't visibly show "Email" anywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The trick
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Class-name brittle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;followBtn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;._a9-_._a9-_2._a9-_8._a9-_z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// getByRole — survives layout changes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;followBtn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/follow/i&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first one breaks the day Facebook tweaks their CSS-in-JS hash. The second one keeps working until they remove the button entirely.&lt;/p&gt;

&lt;p&gt;Same for headings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "Get the post title"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heading&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;level&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;That works on every site that uses &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; correctly. Which is most of them, because Google penalises sites that don't.&lt;/p&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%2Fiolpm92cu0hrh01xce5t.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%2Fiolpm92cu0hrh01xce5t.png" alt="Fig. 1 — Selector stability over a 30-day window. getByRole survives layout churn." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick case
&lt;/h2&gt;

&lt;p&gt;The Facebook transcript actor extracts video metadata from public posts. Facebook ships A/B tests constantly — class names change every couple of weeks. Selectors built on &lt;code&gt;_a9-_8&lt;/code&gt; chains broke regularly.&lt;/p&gt;

&lt;p&gt;I rewrote the extractor to use &lt;code&gt;getByRole&lt;/code&gt; for everything that had a meaningful role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Author name → &lt;code&gt;getByRole('link', { name: /^[\w. ]+$/ })&lt;/code&gt; near the post header.&lt;/li&gt;
&lt;li&gt;Post text → no role, but &lt;code&gt;[data-ad-comet-preview="message"]&lt;/code&gt; (a &lt;code&gt;data-&lt;/code&gt; attribute, also stable).&lt;/li&gt;
&lt;li&gt;Video player → &lt;code&gt;getByRole('article')&lt;/code&gt; containing a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before: ~8 selector breakages per quarter. After: 1 in the last 6 months, and that one was a real structural change (Facebook moved to a new post type), not a class rename.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getByRole&lt;/code&gt; is now the first thing every new actor we write tries — including the rebuild of the &lt;a href="https://apify.com/sian.agency/facebook-ai-transcript-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=playwright-getbyrole-stable-selectors" rel="noopener noreferrer"&gt;Facebook AI Transcript Extractor&lt;/a&gt;. CSS-class selectors are reserved for the cases where the site's accessibility story is genuinely broken (rare in 2026 — most sites have been audited at least once).&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open your scraper. Run a search for &lt;code&gt;page.locator(&lt;/code&gt;&lt;/strong&gt; with a CSS class chain. &lt;strong&gt;How many can you replace with &lt;code&gt;getByRole&lt;/code&gt;?&lt;/strong&gt; Drop the count in the comments — I'll bet it's more than half.&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a site where &lt;code&gt;getByRole&lt;/code&gt; falls apart? Reply.&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=playwright-getbyrole-stable-selectors" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Scraping Without Tests Is Gambling. And the House Always Wins.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Fri, 29 May 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/scraping-without-tests-is-gambling-and-the-house-always-wins-3ilh</link>
      <guid>https://dev.to/sian-agency/scraping-without-tests-is-gambling-and-the-house-always-wins-3ilh</guid>
      <description>&lt;p&gt;Nobody writes tests for scrapers. I get it. The site changes, your tests break, you feel like you spent Tuesday writing tests &lt;em&gt;for the site you don't control&lt;/em&gt;. So you skip them.&lt;/p&gt;

&lt;p&gt;Then the site changes again. Your scraper silently returns empty rows. The dashboard goes blank. Your client texts at 11pm. You discover, in the cold light of debug, that this exact failure was deterministic and could have been caught in 30 seconds by a single fixture-based test.&lt;/p&gt;

&lt;p&gt;The house always wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item checklist
&lt;/h2&gt;

&lt;p&gt;What scrapers actually need to test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extraction against a frozen HTML fixture.&lt;/strong&gt; Save a copy of the page once. Run the parser against it. Assert the fields. This catches &lt;em&gt;your&lt;/em&gt; bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validation against a live response.&lt;/strong&gt; Periodically (daily, weekly), hit one real URL and validate the output shape. This catches &lt;em&gt;their&lt;/em&gt; changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoke test the full pipeline against a known-good URL.&lt;/strong&gt; End-to-end. One URL. Asserts that you get one row out, with the expected fields. This catches integration breakage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don't need a Jest config or a pytest empire. You need three test files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replacement: a fixture-first test in &amp;lt;10 lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_extractor.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_scraper.extract&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;extract_comment&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_youtube_comment_extraction&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="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/fixtures/youtube_comment_2026-04-01.html&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_comment&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@somecreator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;likes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1247&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;great video&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your &lt;code&gt;extract_comment(html)&lt;/code&gt; is a pure function — give it HTML, get a dict back. No browser, no network. Runs in milliseconds. Survives a CI minute budget. Catches every regression in your &lt;em&gt;parsing&lt;/em&gt; code instantly.&lt;/p&gt;

&lt;p&gt;Save the fixture by literally hitting the URL once and writing the response to disk:&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;# scripts/refresh_fixture.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.youtube.com/watch?v=...&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/fixtures/youtube_comment_2026-04-01.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_text&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&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;Run it once a quarter. When the test starts failing, refresh the fixture, fix the extractor, commit both. That's the loop.&lt;/p&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%2F033yygnzdasl2ozna51f.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%2F033yygnzdasl2ozna51f.png" alt="Fig. 1 — Mean time to detect a scraper bug. Tests collapse the gap." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick case
&lt;/h2&gt;

&lt;p&gt;On our YouTube comments scraper, fixture-based tests caught &lt;strong&gt;three&lt;/strong&gt; parsing regressions before they ever reached production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A field rename (&lt;code&gt;likeCount&lt;/code&gt; → &lt;code&gt;likeCount&lt;/code&gt; plus a thousand-separator format change).&lt;/li&gt;
&lt;li&gt;A new "pinned" badge that broke our author-name selector.&lt;/li&gt;
&lt;li&gt;A timestamp format change from "2 days ago" to "2d".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three would have shipped silently. The cron would still run. The CSV would still write. The fields would just be wrong or empty. Instead, the test failed in CI on the PR that introduced the change, fifteen minutes after the fixture was last refreshed.&lt;/p&gt;

&lt;p&gt;The cost of writing the test the first time: 20 minutes. The cost of the bugs it caught, if shipped: at minimum a refund and an apology each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;Every actor we ship now starts with three test files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tests/test_extract.py&lt;/code&gt; — fixture-based unit tests for parsing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tests/test_schema.py&lt;/code&gt; — Pydantic / Zod schema check on a live URL, run on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tests/test_smoke.py&lt;/code&gt; — single-URL end-to-end check on every deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the most boring testing pyramid you've ever seen and it has paid for itself an embarrassing number of times — the &lt;a href="https://apify.com/sian.agency/cheapest-youtube-comments-scraper?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=scraping-without-tests-is-gambling" rel="noopener noreferrer"&gt;YouTube comments scraper&lt;/a&gt; is where it caught the most regressions in 2026.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open your scraper. Do you have a &lt;code&gt;tests/&lt;/code&gt; folder?&lt;/strong&gt; Drop "yes" or "no" in the comments. If "no" — what's stopping you?&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a fixture strategy that actually works? Reply.&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=scraping-without-tests-is-gambling" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>softwareengineering</category>
      <category>testing</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Why Your Requests + BeautifulSoup Stack Will Fail in Production</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Tue, 26 May 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/why-your-requests-beautifulsoup-stack-will-fail-in-production-34kl</link>
      <guid>https://dev.to/sian-agency/why-your-requests-beautifulsoup-stack-will-fail-in-production-34kl</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;requests&lt;/code&gt; plus &lt;code&gt;BeautifulSoup&lt;/code&gt; is the right tool for tutorials, side projects, and one-off audits. It is the wrong tool for any scraper that has to run unsupervised, longer than a quarter, against a site that has even basic bot defenses. I've watched a dozen teams discover this the expensive way. Here's the diagnosis and the replacement.&lt;/p&gt;

&lt;p&gt;I'm not anti-&lt;code&gt;requests&lt;/code&gt;. The library is fast, predictable, and elegant. For 30% of scraping tasks it's still what I reach for first. The problem is that the &lt;em&gt;rest&lt;/em&gt; of the scraping pipeline — JavaScript-rendered content, fingerprinting checks, modern auth flows, lazy loading — silently breaks the assumptions &lt;code&gt;requests&lt;/code&gt; is built on.&lt;/p&gt;

&lt;p&gt;Most teams discover this in stages. Here's the timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 1 — "It works"
&lt;/h2&gt;

&lt;p&gt;You write the first version. &lt;code&gt;requests.get(url)&lt;/code&gt; returns 200, &lt;code&gt;BeautifulSoup&lt;/code&gt; parses the response, you find your selectors, you ship. Tests pass against the small URL set you tested with. Lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 2 — "Some pages return empty"
&lt;/h2&gt;

&lt;p&gt;You notice maybe 5% of pages return rows where half the fields are &lt;code&gt;None&lt;/code&gt;. You add a check, log the URL, retry. The retry sometimes works.&lt;/p&gt;

&lt;p&gt;What's actually happening: those pages render their data in JavaScript after the initial response. &lt;code&gt;requests&lt;/code&gt; got the HTML skeleton. The data was never in it. The retries that "work" are coincidence — sometimes the cached page has stale rendered data; sometimes a CDN ships a different variant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 3 — "We're getting 403s"
&lt;/h2&gt;

&lt;p&gt;The target site rolled out a fingerprinting check. &lt;code&gt;requests&lt;/code&gt; sends a default User-Agent that screams &lt;code&gt;python-requests/2.31.0&lt;/code&gt;. You add headers. It works for two days. They tightened the check — now they look at TLS fingerprint, not just User-Agent. &lt;code&gt;requests&lt;/code&gt; uses the system OpenSSL TLS stack, which is different from any real browser's. The block returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 4 — "We need a session, but it's stateful"
&lt;/h2&gt;

&lt;p&gt;Login flow now requires a CSRF token, which is rendered in JavaScript, which &lt;code&gt;requests&lt;/code&gt; can't run. You spend two days reverse-engineering the login flow, find the API endpoint behind it, hit that directly. Works for six weeks. They rotate the auth scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 5 — "Let's just use Playwright"
&lt;/h2&gt;

&lt;p&gt;You finally migrate. Most of the team is annoyed because the rewrite took longer than they wanted. The team that does it later is annoyed for the same reason.&lt;/p&gt;

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

&lt;p&gt;The fundamental issue: &lt;code&gt;requests&lt;/code&gt; is an HTTP client. Modern websites are browser applications. The thing you're scraping is the &lt;em&gt;output of running JavaScript&lt;/em&gt;, not a static document. You can fight that for a while — by reverse-engineering APIs, faking TLS fingerprints, hand-rolling JS interpreters — but you're paying interest on a debt you took on the day you reached for &lt;code&gt;requests&lt;/code&gt; instead of a real browser.&lt;/p&gt;

&lt;p&gt;Specific failure modes you're going to hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript-rendered content.&lt;/strong&gt; The HTML you fetch contains &lt;code&gt;&amp;lt;div id="root"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt; and not much else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS fingerprinting.&lt;/strong&gt; &lt;code&gt;requests&lt;/code&gt; looks like Python; real browsers look like Chrome/Firefox. Block lists distinguish them easily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-loading.&lt;/strong&gt; Data appears in the DOM only after scroll, click, or visibility events. Static fetch never triggers them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modern auth.&lt;/strong&gt; OAuth, CSRF tokens injected via JS, cookie-based session validation that requires running scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-automation challenges.&lt;/strong&gt; Cloudflare, PerimeterX, DataDome — all rely on running JavaScript to validate the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;requests&lt;/code&gt; answers none of these. Playwright (or Puppeteer) answers all of them, because Playwright &lt;em&gt;is&lt;/em&gt; a browser.&lt;/p&gt;

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

&lt;p&gt;Skip the year of pain. Start with Playwright. Use &lt;code&gt;requests&lt;/code&gt; only when you've measured that the data is in the static HTML and the site has no fingerprinting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.async_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;async_playwright&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;scrape&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;browser&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headless&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;ctx&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="o"&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;viewport&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;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Block heavy resources for speed.
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.{png,jpg,jpeg,gif,svg,woff,woff2}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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="nf"&gt;abort&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domcontentloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Wait for the *data* to appear, not just the document.
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_selector&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-product-id]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15_000&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;extract_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five things &lt;code&gt;requests&lt;/code&gt; can't give you that Playwright does for free:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;JavaScript execution — your selectors target rendered DOM, not the source.&lt;/li&gt;
&lt;li&gt;Realistic TLS fingerprint — Chromium does this for you.&lt;/li&gt;
&lt;li&gt;Cookie/session handling that matches a real browser.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wait_for_selector&lt;/code&gt; — semantic waits instead of &lt;code&gt;time.sleep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Routing controls — block what you don't need, accelerate what you do.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When &lt;code&gt;requests&lt;/code&gt; is still right
&lt;/h2&gt;

&lt;p&gt;Static documentation sites. Open RSS/Atom feeds. JSON APIs that don't require login. PDFs and CSVs hosted on S3. Anything where you've actually fetched the URL, looked at the response body, and confirmed your data is in it.&lt;/p&gt;

&lt;p&gt;That's a real category. Just don't assume the &lt;em&gt;next&lt;/em&gt; site you scrape will fall into it.&lt;/p&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%2F1ojfcfeip0812d5iidnz.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%2F1ojfcfeip0812d5iidnz.png" alt="Fig. 1 — Failure modes by stack. requests+BS4 hits four walls a real browser doesn't." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Across our actor portfolio, the migration ratio settled around 80/20 — Playwright for 80% of jobs, &lt;code&gt;requests&lt;/code&gt; for the 20% where the data is genuinely static. The 80% includes our entire &lt;a href="https://apify.com/sian.agency/best-sephora-product-catalog-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=requests-beautifulsoup-fails-production" rel="noopener noreferrer"&gt;Sephora catalog pipeline&lt;/a&gt;, which spent its first version as a &lt;code&gt;requests + BeautifulSoup&lt;/code&gt; script and never made it past month 2. The Playwright rewrite has been running unsupervised for 14 months.&lt;/p&gt;

&lt;p&gt;If your scraper is currently 100% &lt;code&gt;requests&lt;/code&gt;, your sample size isn't "this works fine." Your sample size is "the sites I've scraped so far happen to have static HTML."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which of the five failure modes have you shipped to production?&lt;/strong&gt; Drop the symptom in the comments — I'll point at the fix.&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=requests-beautifulsoup-fails-production" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>softwareengineering</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Stop Fighting the DOM. Selector-First Thinking Will Save Your Scraper.</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Sun, 24 May 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/stop-fighting-the-dom-selector-first-thinking-will-save-your-scraper-2bp9</link>
      <guid>https://dev.to/sian-agency/stop-fighting-the-dom-selector-first-thinking-will-save-your-scraper-2bp9</guid>
      <description>&lt;p&gt;Most broken scrapers I see have the same shape: someone wrote the extraction logic &lt;em&gt;first&lt;/em&gt; and the selectors &lt;em&gt;second&lt;/em&gt;. The selectors were an afterthought — whatever worked in DevTools at 2am.&lt;/p&gt;

&lt;p&gt;That's backwards. Selectors are the contract between your code and the page. Get them wrong and the rest of your scraper is irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift
&lt;/h2&gt;

&lt;p&gt;Selector-first thinking means: before you write a single line of extraction code, you decide &lt;em&gt;how the data is identified&lt;/em&gt;. Not "how do I get the price?" but "what does the page tell me, programmatically, that this thing is a price?"&lt;/p&gt;

&lt;p&gt;Three answers, in order of preference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Semantics&lt;/strong&gt; — &lt;code&gt;getByRole&lt;/code&gt;, &lt;code&gt;getByLabel&lt;/code&gt;, &lt;code&gt;getByText&lt;/code&gt;. These mirror what an accessibility tree exposes. They survive design changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data attributes&lt;/strong&gt; — &lt;code&gt;data-testid&lt;/code&gt;, &lt;code&gt;data-product-id&lt;/code&gt;, &lt;code&gt;itemprop&lt;/code&gt;. Devs often add these for their own tests; you get to free-ride.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data&lt;/strong&gt; — JSON-LD, microdata, OpenGraph. The page is already telling Google what's a price; let it tell you too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CSS classes are last resort. Class names are styling, not identity. They change when the design changes. They're the equivalent of asking for "the third button from the top" — works until someone rearranges the menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item checklist
&lt;/h2&gt;

&lt;p&gt;Before you write a selector:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open the accessibility tree&lt;/strong&gt; in DevTools (Chrome: Elements → Accessibility tab). If the data has a role and an accessible name, use &lt;code&gt;getByRole&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search the page source for &lt;code&gt;application/ld+json&lt;/code&gt;.&lt;/strong&gt; If it's there and contains your fields, parse it directly. No DOM walking needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for &lt;code&gt;data-*&lt;/code&gt; attributes near the data.&lt;/strong&gt; Devs leave testing hooks everywhere. Use theirs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If none of those work, &lt;em&gt;then&lt;/em&gt; fall back to CSS or XPath. And when you do, anchor to something stable — a parent landmark, an aria-label, a &lt;code&gt;data-&lt;/code&gt; attribute — not just a class chain.&lt;/p&gt;

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

&lt;p&gt;Here's the priority I use in every new actor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Structured data first.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ld&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[type="application/ld+json"]&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="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;textContent&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ld&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Semantic selectors.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceByLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^price$/i&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;priceByLabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;priceByLabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Data attributes.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceByData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="price"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;priceByData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;priceByData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Last resort: CSS class. Logged loudly so we know we're in fallback.&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Falling back to CSS selector — selector audit needed.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.price-tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;textContent&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;Notice the warn() in the fallback path. When that warning starts appearing in your logs, it means the site changed its higher-priority signals and you're one design refresh away from breakage. Fix it before it breaks, not after.&lt;/p&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%2Fmrzau5rmfeyc65gx9tm3.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%2Fmrzau5rmfeyc65gx9tm3.png" alt="Fig. 1 — Selector-priority ladder. Top is most stable. Bottom is most fragile." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick case
&lt;/h2&gt;

&lt;p&gt;On our Idealista actor, the priority order above turned a "fix the selector every 6 weeks" routine into a "fix the selector twice a year" routine. The JSON-LD path catches 95% of listings without ever touching the DOM. The accessibility-role fallback catches another 4%. The CSS fallback fires on edge-case property types and tells us when a new layout has shipped — usually a week before any of our other monitoring would have noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;This selector ladder is the second thing every actor we ship gets, right after the request blocking from last week's post — see it in action in the &lt;a href="https://apify.com/sian.agency/smart-idealista-scraper?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=selector-first-thinking-stop-fighting-dom" rel="noopener noreferrer"&gt;Idealista actor&lt;/a&gt;. It's so consistent we made it a util.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open your scraper's selector code right now.&lt;/strong&gt; Count how many class-name chains you have versus semantic / structured-data lookups. Drop the ratio in the comments. Bonus points for the longest CSS chain — I bet someone has &lt;code&gt;.product-grid &amp;gt; .item:nth-child(3) &amp;gt; .price &amp;gt; span &amp;gt; strong&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a site that genuinely needs CSS chains? Reply.&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=selector-first-thinking-stop-fighting-dom" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>webdev</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>A 10-Line Playwright Trick That Saved Me Hours on Every Sephora Run</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Fri, 22 May 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/a-10-line-playwright-trick-that-saved-me-hours-on-every-sephora-run-512a</link>
      <guid>https://dev.to/sian-agency/a-10-line-playwright-trick-that-saved-me-hours-on-every-sephora-run-512a</guid>
      <description>&lt;p&gt;Most Playwright tutorials teach you to scrape a single page. Real scrapers need to scrape thousands. The thing that kills you isn't the selector — it's everything Playwright does &lt;em&gt;before&lt;/em&gt; it touches the selector.&lt;/p&gt;

&lt;p&gt;By default, Playwright loads a page like a human visiting a website. It downloads CSS, fonts, analytics scripts, A/B testing pixels, hero images, lazy-loaded carousels, and three different chat widgets. On a product catalog page, that's 4–6 MB of stuff you don't need. Times 10,000 pages, that's the difference between a 20-minute run and a 3-hour run.&lt;/p&gt;

&lt;p&gt;Here's the 10-line route handler I drop into every actor:&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;BLOCKED&lt;/span&gt; &lt;span class="o"&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;image&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;media&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;font&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;stylesheet&lt;/span&gt;&lt;span class="dl"&gt;'&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resourceType&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;url&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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="sr"&gt;/google-analytics|doubleclick|hotjar|segment|gtm/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Two lists: resource types you don't need, and tracking domains you definitely don't need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-item checklist before you ship this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test that your data is still there.&lt;/strong&gt; Some sites lazy-load product info into image &lt;code&gt;data-&lt;/code&gt; attributes. Aborting images can sometimes break extraction. Run with and without the route handler and diff the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't block scripts.&lt;/strong&gt; Modern sites build the DOM with JS. Aborting scripts will give you an empty page. (CSS and fonts are safe — Playwright doesn't need them to find selectors.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for sites that detect this.&lt;/strong&gt; Some bot-detection scripts check whether you fetched the analytics pixel. If your success rate drops after enabling this, allow the analytics domains back through.&lt;/li&gt;
&lt;/ol&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%2Fmhxha1lwypf9284884fi.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%2Fmhxha1lwypf9284884fi.png" alt="Fig. 1 — Page weight before vs after the block list. Same DOM, less network." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick case
&lt;/h2&gt;

&lt;p&gt;On our Sephora product info actor, this single change cut average page load from 4.8s to 1.3s. Across a 5000-product catalog scrape, that's the difference between 6.5 hours and 1.8 hours. Same selectors, same data, same success rate. We just stopped downloading hero images of moisturizers we never look at.&lt;/p&gt;

&lt;p&gt;It also dropped our Apify compute units per run by ~60%, which directly affects what we charge customers. Faster scraper, lower cost, same output. The route handler now ships with the &lt;a href="https://apify.com/sian.agency/best-sephora-product-information-extractor?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nova&amp;amp;utm_content=10-line-playwright-resource-blocking" rel="noopener noreferrer"&gt;Sephora product info actor&lt;/a&gt; and every new scraper after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CTA you didn't ask for
&lt;/h2&gt;

&lt;p&gt;This route handler ships with our starter actor template. New scrapers get it on day one. Old scrapers got it bolted on the first time we noticed runtime &amp;gt; 1 hour.&lt;/p&gt;

&lt;p&gt;The pattern works on any browser-based scraper — Playwright, Puppeteer, Selenium with CDP. The shape is always: tell the browser what &lt;em&gt;not&lt;/em&gt; to load, before you tell it what to find.&lt;/p&gt;

&lt;p&gt;One quick note for the JS-heavy among you: the same pattern applies to Puppeteer's &lt;code&gt;page.setRequestInterception(true)&lt;/code&gt; — same idea, slightly different API. Same wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop your slowest scraper's runtime in the comments.&lt;/strong&gt; I'll guess what's eating your minutes. (Hint: it's probably hero images.)&lt;/p&gt;

&lt;p&gt;Agree, disagree, or have a site where blocking images breaks something subtle? Reply.&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=10-line-playwright-resource-blocking" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Stop Building Fragile Scrapers — Build Actors Instead</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Mon, 18 May 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/sian-agency/stop-building-fragile-scrapers-build-actors-instead-2ifc</link>
      <guid>https://dev.to/sian-agency/stop-building-fragile-scrapers-build-actors-instead-2ifc</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A "scraper" is a script that ran once. An "actor" is a unit of work with an input contract, an output schema, observability, and a billing model. Same code, completely different operational surface. We migrated our Bayut property pipeline from the first to the second this quarter and the support load dropped 70%.&lt;/p&gt;

&lt;p&gt;I get sent a lot of scraper repos to "review" — usually after they've broken in production. They look surprisingly similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One Python file, 300–600 lines.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;main()&lt;/code&gt; that loops over URLs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requests.get()&lt;/code&gt; plus &lt;code&gt;BeautifulSoup&lt;/code&gt; plus a &lt;code&gt;try/except: pass&lt;/code&gt; that swallows everything.&lt;/li&gt;
&lt;li&gt;Output written to a CSV called &lt;code&gt;output.csv&lt;/code&gt; in the working directory.&lt;/li&gt;
&lt;li&gt;A cron job that triggers it nightly. Sometimes a Slack webhook on failure that stopped working six months ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what I call &lt;strong&gt;a script that ran once&lt;/strong&gt;. The fact that it ran in production doesn't make it production code.&lt;/p&gt;

&lt;p&gt;The teardown is always the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five failure modes you inherit when you ship a script
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No input contract.&lt;/strong&gt; The script reads URLs from a hardcoded list or a file path that only exists on your laptop. New requirement → edit the file → redeploy → hope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No output schema.&lt;/strong&gt; Whatever fields happened to be present this run get written. When the source site adds a column, the CSV silently widens. When the source site removes a column, downstream breaks at parse time, three hops away from the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No observability.&lt;/strong&gt; "Did it run last night?" is answered by SSH-ing to the box and &lt;code&gt;ls -la output.csv&lt;/code&gt;. Run history is the file's mtime. Failure mode is "the file is older than expected."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries with backoff.&lt;/strong&gt; A 503 from the target site at 02:14 kills the run. There is no second attempt. The next run is in 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No billing surface.&lt;/strong&gt; The cost of running it is your time and your server. There is no per-unit price, so there is no signal that the unit economics are bad until you check the AWS bill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A script is fine for "I need this data once." It is not fine for "we need this data nightly for the next two years." But teams keep shipping #1 to fulfill #2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an actor is
&lt;/h2&gt;

&lt;p&gt;Strip the marketing word and an actor is just: a containerised job with a declared input schema, a declared output schema, and a runtime that handles scheduling, retries, logs, persistent storage, and billing. Apify is one implementation — there are others. The shape matters more than the vendor.&lt;/p&gt;

&lt;p&gt;When we rebuilt our Bayut property scraper as an actor, four things changed at the level of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Input is validated against a schema before main() runs.&lt;/span&gt;
&lt;span class="c1"&gt;//    Bad input fails fast with a useful error, not silent miss.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&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;getInput&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// INPUT_SCHEMA.json enforces shape&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Output goes to a typed dataset. New fields require a schema&lt;/span&gt;
&lt;span class="c1"&gt;//    change — not a silent CSV widening.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Dataset&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;listingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scrapedAt&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Failures retry with backoff at the platform level.&lt;/span&gt;
&lt;span class="c1"&gt;//    Our code throws; the runtime decides what to do.&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;ScrapeFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;listing-blocked&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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Logs are structured, queryable, and indexed by run.&lt;/span&gt;
&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate-limit&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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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 it. Same Playwright, same selectors, same scraping logic. The difference is that all the boring infrastructure — input validation, output typing, retries, logs, scheduling, billing — is no longer your problem.&lt;/p&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%2Fcnljgnw2tyox3h6lvdcm.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%2Fcnljgnw2tyox3h6lvdcm.png" alt="Fig. 1 — Concerns owned by the developer (script) vs. concerns owned by the runtime (actor)." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;For Bayut specifically, three months after the migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mean time to detect a breakage&lt;/strong&gt; went from ~36 hours (next-day stakeholder complaint) to under 15 minutes (failed runs alert with the offending URL and HTTP status).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support tickets&lt;/strong&gt; dropped 70%. Most of the volume was "the data is missing" — invisible failures from the cron-script era. With per-run datasets, failed runs surface themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per 1000 listings&lt;/strong&gt; went &lt;em&gt;down&lt;/em&gt;, not up. Concurrency at the runtime level is cheaper than spinning up your own queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration itself took about a week. Most of the time was not the scraping logic — that was already there. It was deciding what the input schema &lt;em&gt;should&lt;/em&gt; be, what the output schema &lt;em&gt;should&lt;/em&gt; be, and which fields were "nice to have" vs "the dataset is broken without this."&lt;/p&gt;

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

&lt;p&gt;If you're sitting on a script-shaped scraper right now, the migration order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the input schema. Force every run to declare what it's scraping.&lt;/li&gt;
&lt;li&gt;Write the output schema. Force every row to validate before it gets persisted.&lt;/li&gt;
&lt;li&gt;Move retries from &lt;code&gt;try/except: pass&lt;/code&gt; to the runtime.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;print()&lt;/code&gt; with structured logs.&lt;/li&gt;
&lt;li&gt;Containerise. Whatever runs in &lt;code&gt;python main.py&lt;/code&gt; should run in &lt;code&gt;docker run&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pick a runtime — Apify, your own k8s cron, whatever. The schema work is portable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You do steps 1–5 inside your existing repo. You haven't committed to a vendor yet. By the time you reach step 6, the actor &lt;em&gt;exists&lt;/em&gt; — the runtime is just a deployment target.&lt;/p&gt;

&lt;p&gt;We packaged this migration shape into a starter we use for every new client engagement — same six steps that produced the &lt;a href="https://apify.com/sian.agency/bayut-property-scraper?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=jonas&amp;amp;utm_content=scripts-vs-actors-build-actors-instead" rel="noopener noreferrer"&gt;Bayut property scraper&lt;/a&gt; above. Same six steps, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which of the five failure modes is currently shipping in your stack?&lt;/strong&gt; Drop it in the comments — I'll point at the smallest change that fixes 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=scripts-vs-actors-build-actors-instead" rel="noopener noreferrer"&gt;hire SIÁN Agency&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
      <category>python</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
