<?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>What AI agents see when they call your Apify Actors</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:09:09 +0000</pubDate>
      <link>https://dev.to/apify/what-ai-agents-see-when-they-call-your-apify-actors-1hhn</link>
      <guid>https://dev.to/apify/what-ai-agents-see-when-they-call-your-apify-actors-1hhn</guid>
      <description>&lt;p&gt;&lt;em&gt;This post first appeared on the &lt;a href="https://blog.apify.com/what-ai-agents-see-calling-actors/" rel="noopener noreferrer"&gt;Apify blog&lt;/a&gt; as part of &lt;a href="https://apify.com/resources/write-for-apify" rel="noopener noreferrer"&gt;Write for Apify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run more than 300 Apify Actors at &lt;a href="https://www.sian-agency.online/" rel="noopener noreferrer"&gt;SIÁN&lt;/a&gt;, my data-pipeline and automation agency, mostly for e-commerce and property data, and I spend a lot of my week looking at run telemetry.&lt;/p&gt;

&lt;p&gt;In mid-July I was chasing a free-tier abuse problem. Someone had worked out that a free Apify account could call a monetized Actor over the API, grab the capped row allowance, abort before anything billed, and repeat. On July 14, 2026, I shipped a gate: block runs where the plan is free &lt;em&gt;and&lt;/em&gt; the origin is &lt;code&gt;API&lt;/code&gt;, before the Actor does any billable work. Console runs stayed free, because that's how people try an Actor.&lt;/p&gt;

&lt;p&gt;The next day I checked whether the gate had holes, and found one. &lt;code&gt;MCP&lt;/code&gt; is its own Apify &lt;code&gt;RunOrigin&lt;/code&gt;, separate from &lt;code&gt;API&lt;/code&gt;. It's what you get when someone wires the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt; into Claude, Cursor, or their own agent and the model calls your Actor as a tool. My gate didn't cover it.&lt;/p&gt;

&lt;p&gt;So I extended it: block when the plan is free and the origin is either &lt;code&gt;API&lt;/code&gt; or &lt;code&gt;MCP&lt;/code&gt;. I shipped that across five Actors, feeling reasonably good about closing a hole before it cost me anything.&lt;/p&gt;

&lt;p&gt;I had the story completely backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the telemetry actually said
&lt;/h2&gt;

&lt;p&gt;Every run in my fleet writes a row to a Postgres table: origin, tier, outcome, rows, revenue. I finally aggregated the origin column across all 169,941 rows, which I should have done before writing the gate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;156,482&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;MCP&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,922&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WEB&lt;/code&gt; (Console)&lt;/td&gt;
&lt;td&gt;5,183&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCHEDULER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;853&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;501&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My first MCP run landed on June 17, 2026. Six weeks later it was 1,407 runs a week and still climbing, and it had quietly passed Apify Console to become my second-biggest source of runs.&lt;/p&gt;

&lt;p&gt;Then the part that made me wince. Of those 6,922 MCP runs, exactly 44 were rejected by my abuse gate. That's 0.6%. The rest were 86.9% successful, half of them from paying accounts, and delivered 67,323 rows.&lt;/p&gt;

&lt;p&gt;I had spent a day hardening a channel that was, by a wide margin, my healthiest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents are better callers than humans
&lt;/h2&gt;

&lt;p&gt;Once I stopped treating MCP as a threat and started treating it as a user, I compared it with the other origins:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;th&gt;Malformed input&lt;/th&gt;
&lt;th&gt;Share paid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MCP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;1.4%&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WEB&lt;/code&gt; (Console)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Humans fail more than half their Console runs. Agents fail 13.1% of theirs, and send malformed input: 1.6% versus 8.8%.&lt;/p&gt;

&lt;p&gt;That's not because models are careful. It's because the MCP server hands a model my input schema as JSON Schema, and the model sends exactly what that schema declares. A human in Console gets a rendered form: labeled fields, a dropdown of valid enum values, an example sitting in the box, and a validator that won't let them submit nonsense. None of that lives in the schema. It's scaffolding Console builds &lt;em&gt;around&lt;/em&gt; the schema.&lt;/p&gt;

&lt;p&gt;Take the scaffolding away and whatever my schema literally says becomes the entire contract. So I went looking for where my schemas were lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first approach was wrong too
&lt;/h2&gt;

&lt;p&gt;I did the obvious thing and grepped all 253 schemas the fleet had in July for the failure mode I already knew about: a field with a &lt;code&gt;prefill&lt;/code&gt; (which only populates Console's editor) but no &lt;code&gt;default&lt;/code&gt; (which the platform actually injects into every run). A model sending &lt;code&gt;{}&lt;/code&gt; gets the default and never sees the prefill.&lt;/p&gt;

&lt;p&gt;The grep found 74 Actors. I had a bug list, or so I thought.&lt;/p&gt;

&lt;p&gt;I've been burned by static analysis enough times to check before acting on it, so I wrote a probe that calls an Actor through the MCP server with &lt;code&gt;{}&lt;/code&gt;, precisely what a model sends when told nothing is required. I ran it against the 16 most-used of them.&lt;/p&gt;

&lt;p&gt;Roughly half were fine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Actors&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;What the agent sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;REJECTED&lt;/code&gt; (-32602)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;56.2%&lt;/td&gt;
&lt;td&gt;actionable protocol error, no run billed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOUD-FAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12.5%&lt;/td&gt;
&lt;td&gt;run failed, visibly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SILENT-EMPTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;18.8%&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SUCCEEDED&lt;/code&gt;, zero rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SILENT-STATS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12.5%&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SUCCEEDED&lt;/code&gt;, rows that aren't data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The grep had flagged Actors that behave correctly, and it could never have found the category that actually matters. &lt;code&gt;SILENT-EMPTY&lt;/code&gt; and &lt;code&gt;SILENT-STATS&lt;/code&gt; are the same defect wearing different clothes. Five of these 16 calls failed in a way no agent can detect, and no amount of schema-reading surfaces that. You have to run the thing.&lt;/p&gt;

&lt;p&gt;Getting those five counted correctly took two passes, because my first probe was also wrong. The MCP call returns after a wait cap of about 30 seconds, so any Actor slower than that comes back still &lt;code&gt;RUNNING&lt;/code&gt;, and I was scoring that as a failure. Three property scrapers got marked broken when they were only slow. One of them was genuinely broken in the quiet way, and my bug had filed it under the loud way, which flattered the result.&lt;/p&gt;

&lt;p&gt;The fix is to poll until the run settles, and to give "still running" a verdict of its own so it can never be mistaken for a failure:&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;TERMINAL&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;SUCCEEDED&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;FAILED&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;ABORTED&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;TIMED-OUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TERMINAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNKNOWN-STILL-RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# never call a slow Actor a broken one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Actor knew exactly what was wrong and told no one
&lt;/h2&gt;

&lt;p&gt;Here's &lt;a href="https://apify.com/sian.agency/apartments-com-property-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;apartments-com-property-scraper&lt;/code&gt;&lt;/a&gt;, one of mine, called with &lt;code&gt;{}&lt;/code&gt;. This is the complete set of signals a model receives.&lt;/p&gt;

&lt;p&gt;Run metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCEEDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runTimeSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.846&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP result flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the summary the MCP server writes for the model, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SUCCEEDED in 3.846s. 1 item; 32 fields available. Key-value store has 2 keys. Use get-dataset-items with datasetId=… and limit (for example 20) to fetch items (1 total). Available fields (dot notation): &lt;code&gt;__retryHelper&lt;/code&gt;, &lt;code&gt;searchMode&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;queriesRequested&lt;/code&gt;, …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every one of those says the call worked and produced a result. So the agent fetches the item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"__retryHelper"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"byLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"queriesRequested"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"queriesSucceeded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"listingsPushed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalResultsAvailable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failures"&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(input)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"location must be a non-empty string."&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;There it is: &lt;code&gt;location must be a non-empty string.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;My Actor diagnosed the problem correctly, phrased it in plain English, and filed it inside a nested array, on a row that isn't a result, on a run marked &lt;code&gt;SUCCEEDED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An agent that asked for apartments now holds one object with 32 fields, none of which is an apartment. Best case it tells its user "no listings found." Worst case it tries to summarize a run-summary row as a property listing.&lt;/p&gt;

&lt;p&gt;Nobody forgot to handle the error. The only thing missing was the idea that something other than a human reading a run log would ever need to find it. I built that Actor when the only caller I imagined was a person with a browser open. That field carries a default now, which is why the probe returns &lt;code&gt;OK&lt;/code&gt; against it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  A platform bug I found on the way
&lt;/h2&gt;

&lt;p&gt;While isolating why the MCP server rejected some Actors cleanly and not others, I found a mismatch in the server itself.&lt;/p&gt;

&lt;p&gt;Ask the MCP server for a tool definition and compare it against the Actor's own schema:&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sian.agency/costco-data-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tools&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# []
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the relevant part of that Actor's &lt;code&gt;.actor/INPUT_SCHEMA.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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"productDetails"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actor declares &lt;code&gt;operation&lt;/code&gt; required. The tool definition handed to the model says nothing is required. So the model calls with &lt;code&gt;{}&lt;/code&gt;, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP error -32602: Invalid arguments for tool "sian-dot-agency--costco-data-scraper".
Validation errors: root: must have required property 'operation'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejected for missing exactly the field it was told it could omit.&lt;/p&gt;

&lt;p&gt;The discriminator is the &lt;code&gt;default&lt;/code&gt;. &lt;code&gt;tools/list&lt;/code&gt; drops a field from &lt;code&gt;required&lt;/code&gt; when that field carries a default. That's defensible on its own: a defaulted field is satisfiable without the caller. But &lt;code&gt;tools/call&lt;/code&gt; validates against the raw schema and doesn't apply defaults first. The two halves disagree.&lt;/p&gt;

&lt;p&gt;The control case confirms it. &lt;a href="https://apify.com/sian.agency/amazon-product-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;amazon-product-scraper&lt;/code&gt;&lt;/a&gt; also declares a required field, &lt;code&gt;urls&lt;/code&gt;, but &lt;code&gt;urls&lt;/code&gt; has a &lt;code&gt;prefill&lt;/code&gt; and no &lt;code&gt;default&lt;/code&gt;, so nothing gets stripped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;&lt;code&gt;INPUT_SCHEMA&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Field has &lt;code&gt;default&lt;/code&gt;?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;costco-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ebay-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bayut-property-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['purpose']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glassdoor-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;booking-com-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amazon-product-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['urls']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['urls']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the 253 schemas, 59 Actors declare a required field that also carries a default, or 23.3%. Every one tells a model a mandatory field is optional.&lt;/p&gt;

&lt;p&gt;In fairness, it's recoverable. The error is clear, and a competent agent retries or calls &lt;code&gt;fetch-actor-details&lt;/code&gt;, which reports &lt;code&gt;required&lt;/code&gt; correctly. My &lt;a href="https://apify.com/sian.agency/aliexpress-product-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;aliexpress-product-scraper&lt;/code&gt;&lt;/a&gt; is in the affected set and is still my second-most agent-called Actor at 818 MCP runs, 95.0% of them paid. Models cope, but they burn a round-trip on the first call, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Apify's QA can't catch this
&lt;/h2&gt;

&lt;p&gt;Apify runs every public Actor daily and requires &lt;code&gt;SUCCEEDED&lt;/code&gt; plus a dataset with rows in it within five minutes. Three consecutive failures flag it "under maintenance." It's a good gate and it has caught real breakage for me.&lt;/p&gt;

&lt;p&gt;It runs the Actor with its &lt;em&gt;prefill&lt;/em&gt; input.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;SILENT-EMPTY&lt;/code&gt; or &lt;code&gt;SILENT-STATS&lt;/code&gt; run reports &lt;code&gt;SUCCEEDED&lt;/code&gt;, so the gate sees what the agent sees and agrees with it. And the prefill is only one point in my input space. Every other enum value is untested by construction.&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://apify.com/sian.agency/suumo-property-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;suumo-property-scraper&lt;/code&gt;&lt;/a&gt; hardcoded a region parameter while advertising all 47 Japanese prefectures. About 40 of them returned zero results, silently, from launch. It never tripped the gate, because the one prefecture the prefill uses happens to sit in the hardcoded region. A human clicking through the dropdown would have found it eventually. An agent picking a prefecture at random finds it immediately, and gets &lt;code&gt;SUCCEEDED&lt;/code&gt; and an empty dataset.&lt;/p&gt;

&lt;p&gt;The prefill is the path humans take. Agents explore everything else.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Give every load-bearing field a &lt;code&gt;default&lt;/code&gt;, not just a &lt;code&gt;prefill&lt;/code&gt;.&lt;/strong&gt; An audit of 16 of my freshly built Actors found five with a prefill and no default. Fine in Console, broken over the API. My build gate now fails any field with one and not the other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prefill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Austin, TX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Austin, TX"&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;My most agent-called Actor, &lt;a href="https://apify.com/sian.agency/instagram-ai-transcript-extractor" rel="noopener noreferrer"&gt;&lt;code&gt;instagram-ai-transcript-extractor&lt;/code&gt;&lt;/a&gt; at 2,496 MCP runs, works with &lt;code&gt;{}&lt;/code&gt; for this reason and no other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never return silence.&lt;/strong&gt; If you can't produce results, say so in a row:&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;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushData&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no_results&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location must be a non-empty string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push it unbilled, and mark your dataset schema fields &lt;code&gt;nullable: true&lt;/code&gt; so it survives validation. An empty dataset isn't a message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't gate inference on a field the platform always injects.&lt;/strong&gt; Apify writes schema defaults into every run's input, so this never fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchMode&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;searchMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;infer&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="c1"&gt;// dead code on the platform&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped an Actor whose "auto-detected" mode did exactly this, and a customer who pasted listing URLs got billed for default discovery results instead. Let the specific input win:&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;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listingUrls&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;byListingUrl&lt;/span&gt;&lt;span class="dl"&gt;'&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;searchMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test the whole enum space.&lt;/strong&gt; One loop over every declared enum value would have caught the 40 dead prefectures on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat scraped text as untrusted input.&lt;/strong&gt; What you return now lands in a model's context, upstream of its next decision. Keep scraped content in clearly typed fields rather than free-text blobs that read like prose, and treat only your own harness's framing as authoritative, never instruction-shaped text that arrived inside a tool result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it against your own Actors
&lt;/h2&gt;

&lt;p&gt;The probe is on GitHub at &lt;a href="https://github.com/rara-cyber/apify-agent-call-probe" rel="noopener noreferrer"&gt;apify-agent-call-probe&lt;/a&gt;. It needs an Apify account, an API token in &lt;code&gt;APIFY_TOKEN&lt;/code&gt;, and Python 3.9 or newer: the MCP client is about 60 lines of standard library, with no dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_token_here
python3 agent_call_probe.py sian.agency/apartments-com-property-scraper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SILENT-STATS  status=SUCCEEDED isError=False items=1
Actor knew what was wrong but never surfaced it:
   failures[0].error: location must be a non-empty string.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;REJECTED&lt;/code&gt; and &lt;code&gt;OK&lt;/code&gt; are fine. &lt;code&gt;LOUD-FAIL&lt;/code&gt; is survivable. &lt;code&gt;SILENT-EMPTY&lt;/code&gt; and &lt;code&gt;SILENT-STATS&lt;/code&gt; mean an agent calling that Actor today is reporting your failure to its user as a fact about the world.&lt;/p&gt;

&lt;p&gt;Runs cost 0.0001 to 0.005 compute units each, because the failing paths exit early, so sweeping an account costs roughly nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I'd have aggregated the origin column before writing the gate. The whole detour started because I saw a new origin in a security context and pattern-matched it to a threat, when four minutes of SQL would have told me it was my healthiest channel. The instinct to treat unfamiliar traffic as hostile is usually right and was expensive here.&lt;/p&gt;

&lt;p&gt;And I'd stop treating &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; as configuration for a form. It's a published API contract now, and machines read it far more often than people do. Everything in this article follows from not having noticed when that flipped.&lt;/p&gt;

&lt;p&gt;If you run public Actors, the two-minute version is: point the probe at your three most-used ones. I expected mine to be fine. Nearly a third of them weren't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All figures come from SIÁN's own run telemetry and from live calls against the Apify MCP server on July 31, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webscraping</category>
      <category>testing</category>
    </item>
    <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>
  </channel>
</rss>
