<?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: Mr Zack</title>
    <description>The latest articles on DEV Community by Mr Zack (@tactful_anvil).</description>
    <link>https://dev.to/tactful_anvil</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%2F4073833%2F554e6257-7bf5-41c3-a0e1-15f1d0e45abe.png</url>
      <title>DEV Community: Mr Zack</title>
      <link>https://dev.to/tactful_anvil</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tactful_anvil"/>
    <language>en</language>
    <item>
      <title>Your "only new results" mode has never been tested on the second run</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Fri, 18 Sep 2026 19:48:56 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/your-only-new-results-mode-has-never-been-tested-on-the-second-run-1lf</link>
      <guid>https://dev.to/tactful_anvil/your-only-new-results-mode-has-never-been-tested-on-the-second-run-1lf</guid>
      <description>&lt;p&gt;Half of the scrapers we sell on Apify Store have a monitor switch: &lt;code&gt;onlyNew: true&lt;/code&gt;. Schedule the actor, and every run returns only the rows that were not there last time. Class-action settlements that just opened, Kalshi markets that just listed, startups that just hit the marketplace. It is the one feature that turns a "try once" user into a daily schedule.&lt;/p&gt;

&lt;p&gt;We shipped it to six actors in one afternoon. Unit tests green, cloud verification green: run 1 returned 136 rows, run 2 returned 0. Done.&lt;/p&gt;

&lt;p&gt;Then we tried to write a regression test for it and realised our whole test harness could not express the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harness only knew about run 1
&lt;/h2&gt;

&lt;p&gt;Every test we had followed the same shape: fresh storage directory, write &lt;code&gt;INPUT.json&lt;/code&gt;, run &lt;code&gt;src/main.js&lt;/code&gt;, read the dataset, assert. One run, one dataset. There was no way to say "now run it &lt;em&gt;again&lt;/em&gt; against the same key-value store and check that the second dataset is empty."&lt;/p&gt;

&lt;p&gt;That is not a small gap. The correctness of &lt;code&gt;onlyNew&lt;/code&gt; lives entirely in the second run. Run 1 is indistinguishable from &lt;code&gt;onlyNew: false&lt;/code&gt;. If the seen-set is never persisted, or is persisted under the wrong key, or is loaded but never applied, run 1 still looks perfect. The bug only exists in a state your harness never reaches.&lt;/p&gt;

&lt;p&gt;So we added one field to the spec format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"onlyNew run-2 → 0 rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&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;"onlyNew"&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="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;"runs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minFirstRows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minRows"&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;"maxRows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runs: 2&lt;/code&gt; runs the actor twice in the same storage directory. Between runs the dataset is wiped but the key-value stores are kept — exactly what happens between two scheduled runs on the platform. &lt;code&gt;minFirstRows&lt;/code&gt; guards the first run so a broken scraper that returns nothing twice does not pass as "correctly returned nothing new."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the second run caught on day one
&lt;/h2&gt;

&lt;p&gt;The new spec found no bug in the six actors. It found two bugs in how we &lt;em&gt;thought&lt;/em&gt; about the feature, which is the more useful kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "0 new rows" depends on the cap.&lt;/strong&gt; Our first draft of the spec inherited a base input with &lt;code&gt;maxItems: 50&lt;/code&gt; for the class-action actor. Run 1 returned 50 rows, run 2 returned 50 rows, and the check went red. Not a bug: the board has 194 settlements, the seen-filter runs &lt;em&gt;before&lt;/em&gt; the cap (so unsent rows stay "new"), and run 2 correctly delivered the next 50. The golden case "second run is empty" is only true when the first run drained the board. If your cap is smaller than the source, the honest assertion is "run 2 is disjoint from run 1," not "run 2 is empty." We wrote the spec inputs so the board fits, and documented the disjoint case for later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Some actors cannot be tested twice inside a sandbox call.&lt;/strong&gt; The startup-marketplace scraper walks a 565-URL sitemap; one run is 50 seconds, two runs plus setup is over the time limit of the shell we get. We verified it by hand (409 → 0) and left a note in the spec. A test you cannot run automatically is a test that will silently stop being run; better to know that today than to discover it in a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we took from it
&lt;/h2&gt;

&lt;p&gt;If a feature's behaviour depends on state from a previous run — dedupe memory, trend baselines, "since last time" deltas — then the feature has &lt;em&gt;no coverage&lt;/em&gt; until your harness can run the actor twice. Not "weak coverage." None. Run 1 exercises the code path that writes the memory, and that path is invisible from the dataset.&lt;/p&gt;

&lt;p&gt;We now have seven &lt;code&gt;runs: 2&lt;/code&gt; specs across the catalog (kalshi, YouTube Shorts, MunchEye, class actions, the clipping-campaign aggregator, Microns, Skool). They cost a few seconds each and run with every maintenance pass.&lt;/p&gt;

&lt;p&gt;If you sell a scraper with a monitor mode, go check when you last looked at its second run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We maintain 45+ scrapers on &lt;a href="https://apify.com/tactful_anvil" rel="noopener noreferrer"&gt;Apify Store&lt;/a&gt;. Previous post in this series: &lt;a href="https://dev.to/tactful_anvil/your-smoke-test-passes-your-users-still-crash-3efo"&gt;Your &lt;code&gt;{}&lt;/code&gt; smoke test passes. Your users still crash.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>testing</category>
      <category>javascript</category>
      <category>apify</category>
    </item>
    <item>
      <title>Your `{}` smoke test passes. Your users still crash.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Thu, 17 Sep 2026 19:51:55 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/your-smoke-test-passes-your-users-still-crash-3efo</link>
      <guid>https://dev.to/tactful_anvil/your-smoke-test-passes-your-users-still-crash-3efo</guid>
      <description>&lt;p&gt;We run 45 scrapers on Apify Store. Every one of them gets an automated smoke test every day: start the actor with an empty input &lt;code&gt;{}&lt;/code&gt;, check that it finishes and returns rows. Green across the board for weeks.&lt;/p&gt;

&lt;p&gt;Meanwhile, one of our paid actors — the Amazon one — was failing for real users. Four failed runs in one day, from people who were paying for it.&lt;/p&gt;

&lt;p&gt;The crash was a one-line bug that no &lt;code&gt;{}&lt;/code&gt; test can ever catch. Here's the shape of it, because you probably have the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The actor has several &lt;em&gt;modes&lt;/em&gt;: keyword search, a list of ASINs, or "Best Sellers" rankings. If the user gives none of them, we fall back to a demo search so the run isn't empty:&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;keywords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&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;keywords&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;asins&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&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;asins&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;asins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;bestSellersCategories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;air fryer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// demo&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...eight lines later:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bestSellersCategories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&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;bestSellersCategories&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bestSellersCategories&lt;/code&gt; is a &lt;code&gt;const&lt;/code&gt; declared &lt;em&gt;below&lt;/em&gt; the line that reads it. In JavaScript that isn't &lt;code&gt;undefined&lt;/code&gt; — it's a &lt;code&gt;ReferenceError&lt;/code&gt; (the temporal dead zone). But &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; short-circuits: as long as &lt;code&gt;keywords&lt;/code&gt; has something in it, the bad expression is never evaluated.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;{}&lt;/code&gt; → the platform injects the schema default &lt;code&gt;keywords: ["air fryer"]&lt;/code&gt; → short-circuit → fine.&lt;/li&gt;
&lt;li&gt;Our unit tests → parser only, never boot the actor → fine.&lt;/li&gt;
&lt;li&gt;Our "all paid flags on" test → keywords present → fine.&lt;/li&gt;
&lt;li&gt;A user who opens the Best Sellers mode, &lt;strong&gt;deletes the default keyword&lt;/strong&gt;, and picks a category → &lt;code&gt;keywords: []&lt;/code&gt; → the expression runs → crash on line 1 of the run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mode we put in the &lt;em&gt;title&lt;/em&gt; of the actor was the one that crashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every gate was green
&lt;/h2&gt;

&lt;p&gt;Each of our checks answered a question that wasn't the question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gate&lt;/th&gt;
&lt;th&gt;question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{}&lt;/code&gt; smoke test&lt;/td&gt;
&lt;td&gt;"does the default path work?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unit tests&lt;/td&gt;
&lt;td&gt;"does the parser work?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all-flags-on run&lt;/td&gt;
&lt;td&gt;"do the paid options work &lt;em&gt;on top of the default path&lt;/em&gt;?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"is it syntactically valid?" (TDZ is valid syntax)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them asked: &lt;strong&gt;"does each &lt;em&gt;alternative&lt;/em&gt; input mode work when the primary mode is explicitly empty?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's not a corner case. It's what a real user does the moment they want the second feature instead of the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes, one of them permanent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Move the demo line below the declarations. Thirty seconds.&lt;/li&gt;
&lt;li&gt;Make sure it can't come back:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;A static gate for use-before-declaration.&lt;/strong&gt; ESLint's &lt;code&gt;no-use-before-define&lt;/code&gt; catches this, but we wanted something that runs against the &lt;em&gt;deployed entry point&lt;/em&gt; of all 45 actors in one command, with zero config per actor. Forty lines with &lt;code&gt;acorn&lt;/code&gt;: parse the module, list top-level &lt;code&gt;let/const/class&lt;/code&gt; by statement index, walk every top-level statement (skipping function bodies — they run later) and flag any identifier that resolves to a declaration further down. It found exactly the two amazon lines and nothing else across 160 files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A third smoke-test scenario.&lt;/strong&gt; Our user-path audit ran A (&lt;code&gt;{}&lt;/code&gt;) and B (paid flags on). It now runs C: for every actor with more than one input mode, an input that &lt;em&gt;empties the primary field&lt;/em&gt; and fills the alternative one — &lt;code&gt;{"keywords": [], "bestSellersCategories": ["electronics"]}&lt;/code&gt;, &lt;code&gt;{"queries": [], "sources": ["reuters.com"]}&lt;/code&gt;, &lt;code&gt;{"videoUrls": [], "channelUrls": [...]}&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Scenario C paid for itself within the hour. Running it across the catalogue turned up a second, quieter bug in a different actor: a Google News scraper where &lt;code&gt;sources&lt;/code&gt; &lt;em&gt;without&lt;/em&gt; a query fell back to the top-stories feed and then filtered it against the source list — zero articles, status "Succeeded". Same family: the alternative mode was never exercised on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your actor (or CLI, or API) has more than one way to specify &lt;em&gt;what&lt;/em&gt; to fetch, your smoke tests need one run per way, &lt;strong&gt;with the other ways explicitly empty&lt;/strong&gt; — not absent. Absent gets defaults injected. Empty is what users send.&lt;/p&gt;

&lt;p&gt;And add the TDZ gate. It's the cheapest test you'll ever write: it costs nothing to run and it catches the one class of crash that "it works on &lt;code&gt;{}&lt;/code&gt;" is structurally unable to see.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The gate and the scenario runner live in the tooling behind our &lt;a href="https://apify.com/tactful_anvil" rel="noopener noreferrer"&gt;Apify Store actors&lt;/a&gt;. Previous post in this series: &lt;a href="https://dev.to/tactful_anvil/the-scraper-said-succeeded-the-column-was-dead-for-weeks-38ec"&gt;The scraper said "Succeeded". The column was dead for weeks.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>testing</category>
      <category>javascript</category>
      <category>apify</category>
    </item>
    <item>
      <title>The scraper said 'Succeeded'. The column was dead for weeks.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Wed, 16 Sep 2026 23:31:29 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/the-scraper-said-succeeded-the-column-was-dead-for-weeks-38ec</link>
      <guid>https://dev.to/tactful_anvil/the-scraper-said-succeeded-the-column-was-dead-for-weeks-38ec</guid>
      <description>&lt;p&gt;Here's a bug class that never trips an alert: the run succeeds, the row count is right, the schema validates — and one numeric column has been &lt;code&gt;0&lt;/code&gt; on every single row since the source quietly changed its API.&lt;/p&gt;

&lt;p&gt;We hit it twice in one morning while auditing our Apify actors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: Kalshi &lt;code&gt;liquidity_dollars&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Our &lt;a href="https://apify.com/tactful_anvil/kalshi-weather-markets-scraper" rel="noopener noreferrer"&gt;Kalshi weather markets scraper&lt;/a&gt; maps &lt;code&gt;liquidity_dollars&lt;/code&gt; from Kalshi's &lt;code&gt;/markets&lt;/code&gt; endpoint into a &lt;code&gt;liquidity&lt;/code&gt; column. At some point Kalshi started returning &lt;code&gt;"liquidity_dollars": "0.0000"&lt;/code&gt; for &lt;strong&gt;every&lt;/strong&gt; market — including NFL games with $800k of volume. Nothing failed. 336 rows per run, all green gates, &lt;code&gt;liquidity = 0&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;p&gt;The fix wasn't "remove the column". The same payload still carries &lt;code&gt;yes_bid_size_fp&lt;/code&gt;, &lt;code&gt;yes_ask_size_fp&lt;/code&gt;, &lt;code&gt;yes_bid_dollars&lt;/code&gt; and &lt;code&gt;yes_ask_dollars&lt;/code&gt;, which is enough to compute resting collateral at the top of the book:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;liquidity ≈ yesBidSize × yesBid + yesAskSize × (1 − yesAsk)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero extra requests, and the column is alive again. We also added a &lt;code&gt;liquiditySource&lt;/code&gt; field (&lt;code&gt;kalshi&lt;/code&gt; | &lt;code&gt;topOfBook&lt;/code&gt;) so nobody has to guess where the number came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: Kick &lt;code&gt;likes&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Our Kick clips scraper had &lt;code&gt;likes = 0&lt;/code&gt; on 112/112 rows. We checked the raw API (list &lt;em&gt;and&lt;/em&gt; detail endpoints, 41k-view clips): Kick itself now reports 0 likes for everything. Nothing to compute from — so the honest fix is documentation: the field stays for compatibility, the README says "rank by views / viewsPerHour instead".&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that catches both
&lt;/h2&gt;

&lt;p&gt;We added one rule to our post-run dataset check, and it fired on the very first run:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A numeric column that is non-null on ≥ 50 rows and equals &lt;strong&gt;0 on 100 % of them&lt;/strong&gt; is a warning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fifteen lines of code. It's cheap because "all zeros" is almost never what a real metric looks like; it's valuable because "succeeded + right row count" is exactly the signal that lets dead columns hide.&lt;/p&gt;

&lt;p&gt;If you maintain scrapers, add it today. Skip flags/counters that are legitimately zero (&lt;code&gt;isX&lt;/code&gt;, &lt;code&gt;position&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;) and let everything else scream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: "cap before filter"
&lt;/h2&gt;

&lt;p&gt;Same audit, different actor — an AliExpress search scraper applied &lt;code&gt;maxProducts&lt;/code&gt; first and &lt;code&gt;minSold&lt;/code&gt; after, so &lt;code&gt;minSold: 500, maxProducts: 60&lt;/code&gt; could return 16 rows. Filtering while paging (and enforcing AliExpress' own leaky "Choice" filter client-side) turned that into a full 60. Third time we've found this pattern in our own code; it's now a standing test case for every actor with a cap and a filter.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>testing</category>
      <category>dataengineering</category>
      <category>apify</category>
    </item>
    <item>
      <title>Google News RSS quietly supports after:, before:, site: and -word — I measured what each one actually does</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Sun, 13 Sep 2026 19:47:40 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/google-news-rss-quietly-supports-after-before-site-and-word-i-measured-what-each-one-3dfo</link>
      <guid>https://dev.to/tactful_anvil/google-news-rss-quietly-supports-after-before-site-and-word-i-measured-what-each-one-3dfo</guid>
      <description>&lt;p&gt;Google News has no official API, but its RSS search endpoint (&lt;code&gt;news.google.com/rss/search?q=…&lt;/code&gt;) accepts the same operators as the web search box. I could not find a single place that documents which ones the &lt;em&gt;feed&lt;/em&gt; honours, so I measured them (14 Sep 2026, US/en edition, query &lt;code&gt;nvidia&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Each request fetched &lt;code&gt;https://news.google.com/rss/search?q=&amp;lt;query&amp;gt;&amp;amp;hl=en-US&amp;amp;gl=US&amp;amp;ceid=US:en&lt;/code&gt; with a browser user-agent, and I counted &lt;code&gt;&amp;lt;item&amp;gt;&lt;/code&gt; elements and read &lt;code&gt;pubDate&lt;/code&gt; / &lt;code&gt;&amp;lt;source url&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query&lt;/th&gt;
&lt;th&gt;items&lt;/th&gt;
&lt;th&gt;what came back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;mixed sources, mixed dates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia when:7d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;oldest &lt;code&gt;pubDate&lt;/code&gt; = 7 days ago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia after:2026-09-10 before:2026-09-12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;every &lt;code&gt;pubDate&lt;/code&gt; in 10–12 Sep (the &lt;code&gt;before:&lt;/code&gt; day is included)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia site:reuters.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;only &lt;code&gt;reuters.com&lt;/code&gt; sources — going back to 2005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia site:cnbc.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;only &lt;code&gt;cnbc.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia site:cnbc.com after:2026-09-10 before:2026-09-12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;date window narrows the same source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia -stock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;zero titles contain "stock"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;intitle:nvidia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;100/100 headlines contain "nvidia"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia site:cnbc.com -stock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;vs 100 without the exclusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvidia site:cnbc.com after:2026-09-10 before:2026-09-12 -stock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Four things worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 100 is a hard ceiling per feed.&lt;/strong&gt; No &lt;code&gt;num=&lt;/code&gt;, no pagination. The only way to get more is more feeds — which is exactly what &lt;code&gt;site:&lt;/code&gt; gives you: one feed per publisher returns up to 100 &lt;em&gt;per publisher&lt;/em&gt;. Ten sources = up to 1,000 articles for one keyword.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;after:&lt;/code&gt;/&lt;code&gt;before:&lt;/code&gt; are real date filters.&lt;/strong&gt; The window is inclusive on both ends (UTC days). They combine with &lt;code&gt;site:&lt;/code&gt;, and they override the relative &lt;code&gt;when:&lt;/code&gt; window if you send both — so pick one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;-word&lt;/code&gt; matches the article body, not the headline.&lt;/strong&gt; &lt;code&gt;nvidia site:cnbc.com&lt;/code&gt; in a 3-day window has 37 articles, 23 of them with "stock" in the headline. Add &lt;code&gt;-stock&lt;/code&gt; and &lt;strong&gt;36&lt;/strong&gt; disappear — the 13 extra ones matched in the body. Google is excluding every article that &lt;em&gt;mentions&lt;/em&gt; stocks anywhere. If you want "not in the headline", you have to filter the feed yourself after fetching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Topic and location feeds accept no operators at all.&lt;/strong&gt; &lt;code&gt;headlines/section/topic/TECHNOLOGY&lt;/code&gt; and &lt;code&gt;headlines/section/geo/Chicago&lt;/code&gt; ignore everything in the query string except the locale, so date/source/word filters there must be post-fetch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The links are not the links
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; is a &lt;code&gt;news.google.com/rss/articles/CBMi…&lt;/code&gt; redirect. Decoding it to the publisher URL takes one extra request per article to read a signature from the redirect page, then a batched &lt;code&gt;batchexecute&lt;/code&gt; call — and from a datacenter IP the signature page sometimes comes back empty for a few minutes (HTTP 200, no signature). Three runs in a row got 0/11 signatures from the same IP; two minutes later the same input resolved 11/11. Retrying immediately from the same IP does not help — wait or switch IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this landed
&lt;/h2&gt;

&lt;p&gt;I maintain a Google News actor on Apify that already resolved publisher URLs and pulled article text; today it gained &lt;code&gt;publishedAfter&lt;/code&gt; / &lt;code&gt;publishedBefore&lt;/code&gt;, &lt;code&gt;sources&lt;/code&gt; (one feed per source), &lt;code&gt;excludeSources&lt;/code&gt; and &lt;code&gt;excludeWords&lt;/code&gt;, with every filter also applied post-fetch so topic and location feeds honour them too. Rows dropped by a filter are never billed.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://apify.com/tactful_anvil/google-news-scraper" rel="noopener noreferrer"&gt;https://apify.com/tactful_anvil/google-news-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have measured other operators (&lt;code&gt;inurl:&lt;/code&gt;, &lt;code&gt;OR&lt;/code&gt;, &lt;code&gt;allintext:&lt;/code&gt;), I'd love to add them to the table.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>news</category>
      <category>api</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I scraped the top 1,000 Skool communities. 85% are paid, the median charges $34, and 10 communities hold 30% of the money.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Sun, 13 Sep 2026 09:44:09 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/i-scraped-the-top-1000-skool-communities-85-are-paid-the-median-charges-34-and-10-communities-22mp</link>
      <guid>https://dev.to/tactful_anvil/i-scraped-the-top-1000-skool-communities-85-are-paid-the-median-charges-34-and-10-communities-22mp</guid>
      <description>&lt;p&gt;Skool's Discovery page ranks communities, but it never tells you what the market looks like as a whole. So I pulled the global top 1,000 (34 pages × 30) on 13 September 2026 — rank, members, price, billing interval, creation date — and ran the numbers. Snapshot, one day, public data only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing: free is the exception
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tier&lt;/th&gt;
&lt;th&gt;communities&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;154 (15 %)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$1–9 / month&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$10–29 / month&lt;/td&gt;
&lt;td&gt;227&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$30–49 / month&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$50–99 / month&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$100+ / month&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one-time price&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;846 of 1,000 are paid.&lt;/strong&gt; Median monthly price: &lt;strong&gt;$34&lt;/strong&gt;. In the top 100 the paid share is 95 %; at ranks 901–1000 it's still 85 %. Free communities are bigger on average (median 374 members vs 226 for paid) but they don't dominate the ranking — Skool's algorithm clearly weighs engagement over headcount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Size: the median community is small
&lt;/h2&gt;

&lt;p&gt;Median members: &lt;strong&gt;241&lt;/strong&gt;. Median age: &lt;strong&gt;314 days&lt;/strong&gt;. Median lifetime growth: &lt;strong&gt;under 1 member per day&lt;/strong&gt;. The long tail is very long; a community with 1,000 members is already in the top quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money: brutal concentration
&lt;/h2&gt;

&lt;p&gt;Multiply members by monthly price and you get an &lt;em&gt;upper bound&lt;/em&gt; on gross MRR (not every member pays list price — think of it as "money on the table"). Across the 779 monthly-billed communities that's &lt;strong&gt;≈ $22.4 M / month&lt;/strong&gt;. Of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;top 10 communities hold 30 %&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;top 10 % hold 69 %&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;the median community's potential is &lt;strong&gt;$7.3 k / month&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest three by this measure: a 71 k-member community at $29, a 44 k one at $27, and a 164 k one at $7 — proof that low price × huge audience beats premium pricing at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Growth: who is actually winning
&lt;/h2&gt;

&lt;p&gt;Lifetime growth (members ÷ age) crowns the giants, but it hides momentum: a 45-day-old trading community is adding &lt;strong&gt;115 members/day&lt;/strong&gt;, faster than almost anything with a 500-day history. Age-normalised growth is the fairest way to spot rising communities, and it needs two observations, not one — which is why the scraper now has a &lt;code&gt;trackTrends&lt;/code&gt; mode: run it daily and each row gets &lt;code&gt;membersPerDay&lt;/code&gt;, &lt;code&gt;rankChange&lt;/code&gt;, &lt;code&gt;priceChangeUsd&lt;/code&gt; and a &lt;code&gt;growthSignal&lt;/code&gt; (&lt;code&gt;surging&lt;/code&gt; / &lt;code&gt;growing&lt;/code&gt; / &lt;code&gt;flat&lt;/code&gt; / &lt;code&gt;shrinking&lt;/code&gt; / &lt;code&gt;new&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;Public Skool Discovery JSON, HTTP only, no login. 1,000 rows, ~5 seconds. &lt;code&gt;monthlyRevenuePotentialUsd&lt;/code&gt; = members × monthly price (upper bound). The scraper is on the Apify Store — &lt;a href="https://apify.com/tactful_anvil/skool-communities-scraper" rel="noopener noreferrer"&gt;https://apify.com/tactful_anvil/skool-communities-scraper&lt;/a&gt; — with keyword search, category/price filters, owner enrichment (public MRR badge) and the growth tracker above. If it's useful, a bookmark or review there genuinely helps a small developer.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>webscraping</category>
      <category>creators</category>
      <category>business</category>
    </item>
    <item>
      <title>LinkedIn's public job search silently ignores "contract", "remote" and "entry level". I measured it, then fixed it.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Sat, 12 Sep 2026 19:37:30 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/linkedins-public-job-search-silently-ignores-contract-remote-and-entry-level-i-measured-2o94</link>
      <guid>https://dev.to/tactful_anvil/linkedins-public-job-search-silently-ignores-contract-remote-and-entry-level-i-measured-2o94</guid>
      <description>&lt;p&gt;If you scrape LinkedIn jobs without logging in, you probably use the guest search endpoint (&lt;code&gt;/jobs-guest/jobs/api/seeMoreJobPostings/search&lt;/code&gt;). It takes the same query parameters the website does: &lt;code&gt;f_JT&lt;/code&gt; for job type, &lt;code&gt;f_E&lt;/code&gt; for experience level, &lt;code&gt;f_WT&lt;/code&gt; for remote, &lt;code&gt;sortBy=DD&lt;/code&gt; for newest first.&lt;/p&gt;

&lt;p&gt;Since around 11 September 2026 it accepts all of them — and returns exactly the same result set no matter what you send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Query: &lt;code&gt;keywords=data analyst&lt;/code&gt;, &lt;code&gt;location=United States&lt;/code&gt;. Same request, five variants, first 50 job IDs compared with the unfiltered search (re-run 13 Sep 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;variant&lt;/th&gt;
&lt;th&gt;overlap with unfiltered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;f_JT=C&lt;/code&gt; (contract)&lt;/td&gt;
&lt;td&gt;50 / 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;f_E=2&lt;/code&gt; (entry level)&lt;/td&gt;
&lt;td&gt;50 / 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;f_WT=2&lt;/code&gt; (remote)&lt;/td&gt;
&lt;td&gt;50 / 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sortBy=DD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50 / 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;f_TPR=r86400&lt;/code&gt; (past 24h)&lt;/td&gt;
&lt;td&gt;4 / 50 ← still works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only &lt;code&gt;f_TPR&lt;/code&gt; (posted within), &lt;code&gt;f_AL&lt;/code&gt; (Easy Apply) and &lt;code&gt;f_EA&lt;/code&gt; change the response. No error, no warning: the server just serves the default page. A scraper that trusts the parameters keeps "succeeding" with wrong data, which is the worst kind of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "filter after the fact" isn't enough
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to open each job's detail page (which still has the criteria block: seniority, employment type) and drop what doesn't match. I shipped that first. Then a routine parameter audit found the next bug: with &lt;code&gt;maxJobs: 20&lt;/code&gt; and &lt;code&gt;jobTypes: ["contract"]&lt;/code&gt; the run returned &lt;strong&gt;0 rows&lt;/strong&gt;. Not because there are no contract data-analyst jobs, but because only 20 cards were scanned before filtering, and 0 of those 20 were contracts. In my sample it took 49 detail pages to find the first one.&lt;/p&gt;

&lt;p&gt;So the loop is now: page → detail-check → keep matches → keep paging until &lt;code&gt;maxJobs&lt;/code&gt; matches or a scan cap is hit (default 5× &lt;code&gt;maxJobs&lt;/code&gt;, min 50, max 500). Non-matching jobs cost nothing. A "remote data analyst, 8 jobs" run scans ~30 postings; "contract" scans a lot more and tells you so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for any scraper
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Parameters that are &lt;em&gt;accepted&lt;/em&gt; are not parameters that are &lt;em&gt;honoured&lt;/em&gt;. Test the effect (compare IDs), not the status code.&lt;/li&gt;
&lt;li&gt;If a filter has to be enforced client-side, the scan budget has to be decoupled from the result budget, or rare filters return nothing.&lt;/li&gt;
&lt;li&gt;Put the measurement in a gate you run every day. Mine is a JSON spec: input variant → expected effect (&lt;code&gt;all rows have employmentType ~ /contract/&lt;/code&gt;, &lt;code&gt;≥8 rows&lt;/code&gt;, &lt;code&gt;first 5 ids differ from base&lt;/code&gt;). It caught the 0-row bug the day after I introduced it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scraper (salary parsing, applicant counts, monitor mode, company hiring signals, and now honest filters) is on the Apify Store: &lt;a href="https://apify.com/tactful_anvil/linkedin-jobs-full-details-scraper" rel="noopener noreferrer"&gt;https://apify.com/tactful_anvil/linkedin-jobs-full-details-scraper&lt;/a&gt; — if it saves you an afternoon, a bookmark or a review there helps more than you'd think.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>career</category>
      <category>datascience</category>
      <category>node</category>
    </item>
    <item>
      <title>I pulled the payout ledger of 658 clipping campaigns. The median one pays 12% of its advertised CPM.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Fri, 11 Sep 2026 19:38:34 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/i-pulled-the-payout-ledger-of-658-clipping-campaigns-the-median-one-pays-12-of-its-advertised-cpm-5g0k</link>
      <guid>https://dev.to/tactful_anvil/i-pulled-the-payout-ledger-of-658-clipping-campaigns-the-median-one-pays-12-of-its-advertised-cpm-5g0k</guid>
      <description>&lt;p&gt;If you clip for Whop Content Rewards campaigns, you've seen the pitch: "$1 per 1,000 views", "$2 per 1K", budget $100,000. What the board never shows next to that number is how much of it has actually been paid to creators.&lt;/p&gt;

&lt;p&gt;It turns out the site's own JSON API does publish that — per campaign: &lt;code&gt;paidOutCents&lt;/code&gt;, &lt;code&gt;approvedSubmissionCount&lt;/code&gt;, &lt;code&gt;totalViews&lt;/code&gt;, and the exact &lt;code&gt;listedAt&lt;/code&gt; timestamp. So this week I made my scraper emit those on every row for free (no per-campaign detail fetch) and ran it over the full board: &lt;strong&gt;658 active campaigns, $3.52M of advertised budget.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers (12 Sep 2026, single snapshot)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Paid out so far: $510,929&lt;/strong&gt; — 14.5% of advertised budget. "Budget spent" (which includes pending/held amounts) is $1.54M, three times the paid figure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;369 of 658 campaigns (56%) have paid $0&lt;/strong&gt; to anyone. 254 of those already show more than 100,000 submitted views.&lt;/li&gt;
&lt;li&gt;For the 289 campaigns that have paid something, I computed &lt;code&gt;effectiveRewardPerThousandViews = paidOut / totalViews × 1000&lt;/code&gt; and divided by the headline CPM:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;median: 0.12&lt;/strong&gt; — the typical campaign has paid 12 cents for every dollar its CPM promises per view.&lt;/li&gt;
&lt;li&gt;25th percentile 0.04, 75th percentile 0.28.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;72% pay under a quarter&lt;/strong&gt; of headline. &lt;strong&gt;3% pay 80% or more.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That doesn't mean 88% of views are stolen. Views get rejected (rules, watermarks, reposts), payouts lag approvals, and per-submission caps clip viral outliers. But it does mean the CPM on the card is a ceiling, and the floor is usually much closer to the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same $1/1K, very different campaigns
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;campaign&lt;/th&gt;
&lt;th&gt;headline&lt;/th&gt;
&lt;th&gt;paid out&lt;/th&gt;
&lt;th&gt;views&lt;/th&gt;
&lt;th&gt;effective /1K&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Roblox game (ForgeGUI)&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;td&gt;$96,403&lt;/td&gt;
&lt;td&gt;252M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.38&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;French creator (Yomi Denzel)&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;td&gt;$47,059&lt;/td&gt;
&lt;td&gt;1,357M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.035&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backyard Breaks&lt;/td&gt;
&lt;td&gt;$2.00&lt;/td&gt;
&lt;td&gt;$20,123&lt;/td&gt;
&lt;td&gt;21M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.97&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daimon X Syndicate&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;td&gt;$18,463&lt;/td&gt;
&lt;td&gt;31M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.59&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two campaigns, same $1 CPM, 10× difference in what a thousand views has actually been worth. If you're deciding where to spend a week of clipping, that column matters more than the headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do with it as a clipper
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Filter to &lt;code&gt;paidOutUsd &amp;gt; 0&lt;/code&gt; — someone has already been paid, so the pipeline works.&lt;/li&gt;
&lt;li&gt;Sort by &lt;code&gt;effectiveRewardPerThousandViews&lt;/code&gt; and ignore anything under ~0.25× headline unless the budget is huge and young.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;listedAt&lt;/code&gt; (exact now, not "2w") to catch campaigns under 7 days old — 92 on the board today — where you're competing with fewer creators.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How
&lt;/h2&gt;

&lt;p&gt;Everything above is one run of my Apify Actor (&lt;a href="https://apify.com/tactful_anvil/whop-content-rewards-scraper" rel="noopener noreferrer"&gt;Whop Content Rewards Scraper&lt;/a&gt;, pay-per-result, default input, ~20 seconds). New this week: &lt;code&gt;listedAt&lt;/code&gt;, &lt;code&gt;campaignCreatedAt&lt;/code&gt;, &lt;code&gt;approvedSubmissions&lt;/code&gt;, &lt;code&gt;paidOutUsd&lt;/code&gt;, &lt;code&gt;effectiveRewardPerThousandViews&lt;/code&gt; on every row; &lt;code&gt;listedAfter&lt;/code&gt; now works without the enrichment mode; and a bug I found while auditing it — &lt;code&gt;category&lt;/code&gt; had been rendering as &lt;code&gt;[object Object]&lt;/code&gt; for a day because the API started shipping categories as objects. The scraper has two more regression gates now, because "run succeeded" told me nothing about either.&lt;/p&gt;

&lt;p&gt;If you use it, a review on the Store page is the thing that actually helps an indie actor get found. Method notes are in the README.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>datascience</category>
      <category>sidehustle</category>
      <category>creators</category>
    </item>
    <item>
      <title>I surveyed 8,758 Apify actors before building anything. Then I shipped three scrapers in one day.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Sun, 06 Sep 2026 19:47:01 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/i-surveyed-8758-apify-actors-before-building-anything-then-i-shipped-three-scrapers-in-one-day-3kd4</link>
      <guid>https://dev.to/tactful_anvil/i-surveyed-8758-apify-actors-before-building-anything-then-i-shipped-three-scrapers-in-one-day-3kd4</guid>
      <description>&lt;p&gt;For a month I built scrapers the way most indie devs do: pick a site I find interesting, build it, publish, hope. Thirty-seven actors later the honest scorecard was: one real earner (a Whop clipping-campaign scraper), a handful with a single paying user each, and a long tail of "zero starts".&lt;/p&gt;

&lt;p&gt;So I stopped guessing and pulled the market instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: pull the whole store
&lt;/h2&gt;

&lt;p&gt;Apify's public Store API returns every actor with its 30-day run stats, user counts, rating and price. I pulled the top 8,758 by monthly users (54k listed in total, the rest are dead) and bucketed them by keyword cluster. Monthly active users per cluster, roughly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;MAU&lt;/th&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;MAU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instagram&lt;/td&gt;
&lt;td&gt;133k&lt;/td&gt;
&lt;td&gt;Ads (Google/Meta)&lt;/td&gt;
&lt;td&gt;35k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;114k&lt;/td&gt;
&lt;td&gt;YouTube&lt;/td&gt;
&lt;td&gt;34k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs&lt;/td&gt;
&lt;td&gt;59k&lt;/td&gt;
&lt;td&gt;Reddit&lt;/td&gt;
&lt;td&gt;16k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Maps&lt;/td&gt;
&lt;td&gt;57k&lt;/td&gt;
&lt;td&gt;Leads / contacts&lt;/td&gt;
&lt;td&gt;12k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;50k&lt;/td&gt;
&lt;td&gt;Transcripts&lt;/td&gt;
&lt;td&gt;11k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then the fun part: inside each cluster, is the leader &lt;strong&gt;hated&lt;/strong&gt; (≥250 MAU but ≤3.6★), &lt;strong&gt;expensive&lt;/strong&gt; (≥$10 per 1,000 results) or &lt;strong&gt;broken&lt;/strong&gt; (≥35% failed runs)? Those are the three gaps a newcomer can actually take.&lt;/p&gt;

&lt;p&gt;Some examples that fell out of the data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Airbnb: 1,900 MAU across 48 actors, leader at 754 MAU with &lt;strong&gt;2.5★&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;YouTube transcripts: 11k MAU, leader charges &lt;strong&gt;$10 / 1,000&lt;/strong&gt; and sits at 3.7★.&lt;/li&gt;
&lt;li&gt;Google Trends: leader has a &lt;strong&gt;37% failure rate&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;LinkedIn jobs: 47k MAU, leader is fine ($2 / 1,000, 16k MAU) — the gap is depth, not quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: probe before building
&lt;/h2&gt;

&lt;p&gt;A gap is worthless if the site blocks datacenter IPs. So each candidate got a 10-minute HTTP probe from a plain server: fetch the listing page, fetch a detail page, hit whatever JSON the front-end calls, count 403s.&lt;/p&gt;

&lt;p&gt;Airbnb, LinkedIn's guest job API and YouTube's watch page all answered 200. Google Trends (429), eBay (403 even with browser fingerprints), TikTok Shop (login wall) and Google Jobs did not. They went on a "needs residential relay" list instead of the build list.&lt;/p&gt;

&lt;p&gt;One thing I learned the hard way: my sandbox turned out to have a residential IP, so "passed the datacenter probe" only became true after the first cloud run. Verify from the platform you deploy to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: build the three that passed
&lt;/h2&gt;

&lt;p&gt;Same day, three actors, each with unit tests against fixtures, a live gate script (10 checks against the real site) and one verification run in the cloud:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/airbnb-market-intel-scraper" rel="noopener noreferrer"&gt;Airbnb Market Intel Scraper&lt;/a&gt;&lt;/strong&gt; — search results plus, per listing, the 12-month availability calendar. From that you get occupancy and estimated revenue, which is what investors pay AirDNA for. Live gate today for Austin: median nightly price &lt;strong&gt;$166&lt;/strong&gt;, p25 $130, p75 $222; a sample listing at 57% 30-day occupancy, ~$4.8k estimated monthly revenue. $4 / 1,000 listings, $6 with calendar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/linkedin-jobs-full-details-scraper" rel="noopener noreferrer"&gt;LinkedIn Jobs Scraper — Full Description, Salary &amp;amp; Applicants&lt;/a&gt;&lt;/strong&gt; — the guest API gives the card; the detail page gives description, criteria, salary when posted and applicant count ("Over 200 applicants" on two of three sampled data-analyst roles today). It also emits free "company signal" rows: employers ranked by open roles, which is a lead list for recruiters. $1.60 / 1,000 detailed jobs vs the leader's $2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/youtube-transcript-scraper" rel="noopener noreferrer"&gt;YouTube Transcript Scraper&lt;/a&gt;&lt;/strong&gt; — the one that failed the cloud test. YouTube returns the caption track list but the &lt;code&gt;timedtext&lt;/code&gt; endpoint answers 200 with 0 bytes from datacenter IPs. Fix: the actor tries direct first, then falls back to a tiny relay running on a Mac mini behind a Tailscale funnel. Verification run: 7 of 8 videos transcribed, 38 seconds, $0.003. $6 / 1,000 transcripts vs $10.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the survey changed
&lt;/h2&gt;

&lt;p&gt;The old approach optimized for "can I scrape this?". The survey optimizes for "will anyone start this?" — demand first, gap second, feasibility third, build last. One day in: zero paying users, which is exactly what day one looks like. I'll post the 8-week numbers either way.&lt;/p&gt;

&lt;p&gt;The survey script is ~150 lines of Python against one public endpoint. If you publish on any marketplace with a public stats API, do this before your next build.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All three are HTTP-only, pay per result, and callable as MCP tools. If you try one, a review on the store page is the only signal other buyers have — and open an issue if a field is wrong; I fix those fast.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>datascience</category>
      <category>sidehustle</category>
      <category>api</category>
    </item>
    <item>
      <title>211 US class action settlements are open right now, 155 need no receipt. I scrape and score the whole board in one HTTP request.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Sat, 05 Sep 2026 20:08:15 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/211-us-class-action-settlements-are-open-right-now-155-need-no-receipt-i-scrape-and-score-the-24p7</link>
      <guid>https://dev.to/tactful_anvil/211-us-class-action-settlements-are-open-right-now-155-need-no-receipt-i-scrape-and-score-the-24p7</guid>
      <description>&lt;p&gt;Most people find out about a class action settlement the way I used to: a friend forwards a link two days after the claim deadline. The board that lists all of them — ClassAction.org — is public, updates daily, and nobody I know checks it. So I turned it into a dataset.&lt;/p&gt;

&lt;p&gt;This morning's run: &lt;strong&gt;211 open settlements&lt;/strong&gt;, &lt;strong&gt;155 claimable with no proof of purchase&lt;/strong&gt;, &lt;strong&gt;118 of those pay cash&lt;/strong&gt; (not vouchers or credits), &lt;strong&gt;43 added recently&lt;/strong&gt;, &lt;strong&gt;31 closing in the next few days&lt;/strong&gt;. The top of the board right now is a cluster of data-breach settlements paying $50–$5,000 with no receipt required and 40–56 days left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a scraper and not a bookmark
&lt;/h2&gt;

&lt;p&gt;The board is a wall of cards. Each card has a headline, a dollar range somewhere in the text, a deadline, and a "proof of purchase required?" line buried in the details. Comparing 200 of them by hand is the kind of work that makes you give up after page one — which is exactly why so many settlements end with unclaimed money.&lt;/p&gt;

&lt;p&gt;The scraper does three things per card:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse the messy bits.&lt;/strong&gt; Payout text like "Up to $5,000", "$20 - $4,480" or "Varies" becomes &lt;code&gt;payoutMinUsd&lt;/code&gt; / &lt;code&gt;payoutMaxUsd&lt;/code&gt; (nulls stay null — no fake zeros). Deadlines become &lt;code&gt;deadlineDate&lt;/code&gt; + &lt;code&gt;daysLeft&lt;/code&gt; + an &lt;code&gt;urgency&lt;/code&gt; bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flag what matters.&lt;/strong&gt; &lt;code&gt;proofRequired&lt;/code&gt; (tri-state — unknown stays &lt;code&gt;null&lt;/code&gt;), &lt;code&gt;payoutKind&lt;/code&gt; (&lt;code&gt;cash&lt;/code&gt; vs &lt;code&gt;non-cash&lt;/code&gt; vouchers/credits), and the combination people actually want: &lt;code&gt;isNoProofCash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score it.&lt;/strong&gt; &lt;code&gt;claimScore&lt;/code&gt; 0–100 = payout size (log-scaled, 40 pts) + no proof needed (30) + comfortable time window (15) + freshness (15). Sort by it and the "file this tonight" list is the top 20 rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every row also carries the &lt;strong&gt;official claim-site URL&lt;/strong&gt; (not the ClassAction.org page — the actual administrator's site where you file), so the workflow is: run → filter &lt;code&gt;isNoProofCash&lt;/code&gt; → click.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technical part
&lt;/h2&gt;

&lt;p&gt;Nothing exotic. The board is server-rendered HTML — one GET, one parse, ~210 rows, done in a few seconds with no browser and no proxies. The interesting work is in the normalizer:&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;// payout text → numbers, without inventing data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\$([\d&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(?:\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;-–&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\$([\d&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;))?&lt;/span&gt;&lt;span class="sr"&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;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/up to/i&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;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and in the gates I run before every deploy: 13 assertions against the &lt;em&gt;live&lt;/em&gt; page (row count within range, ≥60% rows with a parsed payout, no duplicate settlement IDs, &lt;code&gt;daysLeft&lt;/code&gt; never negative for open cases, &lt;code&gt;claimScore&lt;/code&gt; in [0,100] or null). When ClassAction.org changes its markup — and it will — the gate turns red before a user sees an empty dataset. I learned that lesson the hard way on another Actor that "succeeded" for two days while returning 42 rows instead of 500.&lt;/p&gt;

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

&lt;p&gt;It's an Apify Actor: &lt;a href="https://apify.com/tactful_anvil/classaction-settlements-scraper" rel="noopener noreferrer"&gt;Class Action Settlements Scraper | Claim Deadlines &amp;amp; Payouts&lt;/a&gt;. Default input &lt;code&gt;{}&lt;/code&gt; returns the full board sorted by &lt;code&gt;claimScore&lt;/code&gt;. The inputs I actually use:&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;"noProofOnly"&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;"cashOnly"&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;"sortBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claimScore"&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;for the weekly "worth filing" list, and&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;"maxDaysLeft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"noProofOnly"&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="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;on a daily schedule with a Slack/email integration so cash claims closing soon don't slip past. Pay per result — a filtered run is a few cents. It's also MCP-ready if you'd rather have an agent ask "any new no-proof cash settlements this week?" and get a table back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It reads one board (ClassAction.org). Settlements that never make it there won't appear.&lt;/li&gt;
&lt;li&gt;"No proof required" is what the settlement says; administrators can still ask for an attestation. Always read the official claim site before filing.&lt;/li&gt;
&lt;li&gt;This is a data tool, not legal advice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build on it, the changelog and a "found this useful?" section are in the README — a bookmark or a short review on the Store page is what tells me which fields to add next. Issues tab gets answered within 24 hours.&lt;/p&gt;

&lt;p&gt;Related Actors from the same series: &lt;a href="https://apify.com/tactful_anvil/bulk-email-verifier" rel="noopener noreferrer"&gt;Bulk Email Verifier&lt;/a&gt;, &lt;a href="https://apify.com/tactful_anvil/contact-details-scraper" rel="noopener noreferrer"&gt;Contact Details Scraper&lt;/a&gt;, &lt;a href="https://apify.com/tactful_anvil/microns-startup-listings-scraper" rel="noopener noreferrer"&gt;Microns Startup Listings Scraper&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>node</category>
      <category>datascience</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Kalshi's API is flooded with 8,000+ zero-volume parlay markets. If your scraper reads /markets, it's been empty since Sept 4.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Fri, 04 Sep 2026 19:46:50 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/kalshis-api-is-flooded-with-8000-zero-volume-parlay-markets-if-your-scraper-reads-markets-b3</link>
      <guid>https://dev.to/tactful_anvil/kalshis-api-is-flooded-with-8000-zero-volume-parlay-markets-if-your-scraper-reads-markets-b3</guid>
      <description>&lt;p&gt;Yesterday one of my Apify Actors — a cross-platform "biggest movers" feed for Polymarket and Kalshi — ran, succeeded, and delivered 100 rows. Every single row was Polymarket. The Kalshi half of the product had silently gone to zero, and nothing in the run status said so.&lt;/p&gt;

&lt;p&gt;Here's what happened, how I caught it, and the one query parameter that fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed on Kalshi
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET https://api.elections.kalshi.com/trade-api/v2/markets?status=open&amp;amp;limit=1000&lt;/code&gt; used to return a sensible mix of open markets. Since &lt;strong&gt;September 4, 2026&lt;/strong&gt; the first pages look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page 1: 1000 markets, 1000 multivariate, 0 with 24h volume
page 2: 1000 markets, 1000 multivariate, 0 with 24h volume
...
page 8: 1000 markets, 1000 multivariate, 0 with 24h volume
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8,000 markets in a row, all with a ticker like &lt;code&gt;KXMVECROSSCATEGORY-SHARD1-S2026…-FE61…&lt;/code&gt;, a &lt;code&gt;mve_collection_ticker&lt;/code&gt; field, a 26-leg &lt;code&gt;mve_selected_legs&lt;/code&gt; array (26 college-football moneylines chained together), &lt;code&gt;volume_24h_fp: "0.00"&lt;/code&gt; and &lt;code&gt;last_price_dollars: "0.0000"&lt;/code&gt;. They are auto-generated multivariate ("parlay") combinations. They're valid markets, they're just not something anyone is trading, and there are thousands of them created every day.&lt;/p&gt;

&lt;p&gt;My Actor's Kalshi loop scanned "up to 4× the requested market count" — 800 markets — and stopped. All 800 were parlay shards. Zero Kalshi rows. The Polymarket leg still worked, so the dataset was non-empty, so the run was &lt;code&gt;SUCCEEDED&lt;/code&gt;. This is the failure mode I'm most afraid of: &lt;strong&gt;success-rate stays 100% while the output quietly loses half its value.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I caught it
&lt;/h2&gt;

&lt;p&gt;Not from the run status. I run a free local canary for every Actor before I look at the cloud: the production parser hit the live source from my own machine, with gates on &lt;em&gt;counts and field coverage&lt;/em&gt;, not just exit codes. Yesterday's gate for this Actor:&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="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;polymarket&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;markets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;FAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;kalshi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;markets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;w/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="err"&gt;h&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;vol&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;scanned&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;filters&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;FAIL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;both&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;platforms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;top&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"polymarket"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two red lines. If I had only checked "did it succeed / did it return 100 rows", I'd have seen nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;mve_filter=exclude&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Kalshi's API has an (under-documented) filter for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /trade-api/v2/markets?status=open&amp;amp;limit=1000&amp;amp;mve_filter=exclude
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, page 1 goes from 0 to 45 markets with 24-hour volume, and by page 4 you have ~600 real, actively traded markets. Ten pages (10k markets) give &lt;strong&gt;2,588 open Kalshi markets with non-zero 24h volume&lt;/strong&gt; — that's the real universe.&lt;/p&gt;

&lt;p&gt;I also made two defensive changes so this can't recur silently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Skip anything with &lt;code&gt;mve_collection_ticker&lt;/code&gt; or &lt;code&gt;mve_selected_legs&lt;/code&gt; in the normalizer&lt;/strong&gt;, regardless of what the API returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paginate by "useful markets collected", not "raw markets scanned"&lt;/strong&gt; — keep pulling pages (cap 10) until I have at least 2× the requested top-N with volume &amp;gt; 0.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After the fix, the same default run returns 51 Polymarket + 49 Kalshi movers in the top 100, and the Kalshi log line says &lt;code&gt;618 markets with 24h volume fetched&lt;/code&gt; instead of nothing.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;/events?with_nested_markets=true&lt;/code&gt; endpoint is &lt;em&gt;not&lt;/em&gt; flooded (0 MVE events on page 1), so if you're already on events you're fine. It's specifically &lt;code&gt;/markets&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you scrape Kalshi, check your last few days
&lt;/h2&gt;

&lt;p&gt;Any pipeline built on &lt;code&gt;/markets?status=open&lt;/code&gt; that either (a) stops after N pages or (b) filters on &lt;code&gt;volume_24h &amp;gt; 0&lt;/code&gt; after fetching, has most likely been returning a near-empty Kalshi set since Sept 4. Symptoms: dashboards with only Polymarket, arbitrage scanners with zero Kalshi pairs, "market count" charts falling off a cliff. Add &lt;code&gt;mve_filter=exclude&lt;/code&gt; and re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actor
&lt;/h2&gt;

&lt;p&gt;The fixed feed is live: &lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/prediction-market-movers" rel="noopener noreferrer"&gt;Prediction Market Movers | Polymarket &amp;amp; Kalshi Catalyst Feed&lt;/a&gt;&lt;/strong&gt; — one ranked list of the biggest 24h odds swings, volume spikes and closing-soon markets across both platforms, each with a 0–100 catalyst score and signals like &lt;code&gt;BIG_SWING&lt;/code&gt;, &lt;code&gt;VOLUME_SPIKE&lt;/code&gt;, &lt;code&gt;CLOSING_SOON&lt;/code&gt;, &lt;code&gt;FAST_MOVER_1H&lt;/code&gt;. HTTP-only, public APIs, no keys; you pay per ranked row (~$0.01), and a default run costs about a cent. It also works as an MCP tool if you want an agent to ask "what moved on prediction markets today?"&lt;/p&gt;

&lt;p&gt;Sibling Actors if you want the rest of the stack: &lt;a href="https://apify.com/tactful_anvil/polymarket-kalshi-arbitrage-scanner" rel="noopener noreferrer"&gt;Polymarket ↔ Kalshi Arbitrage Scanner&lt;/a&gt;, &lt;a href="https://apify.com/tactful_anvil/polymarket-top-traders-scraper" rel="noopener noreferrer"&gt;Polymarket Top Traders&lt;/a&gt;, &lt;a href="https://apify.com/tactful_anvil/kalshi-weather-markets-scraper" rel="noopener noreferrer"&gt;Kalshi Weather Markets&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this saved you a debugging session, a ⭐ or bookmark on the Actor page helps a lot — and if you've seen other Kalshi API changes this week, tell me in the comments so I can add gates for them.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>node</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I pulled the open positions of Polymarket's 25 most profitable traders. $7.5M is sitting in just 5 bets.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Thu, 03 Sep 2026 19:33:06 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/i-pulled-the-open-positions-of-polymarkets-25-most-profitable-traders-75m-is-sitting-in-just-5-j8o</link>
      <guid>https://dev.to/tactful_anvil/i-pulled-the-open-positions-of-polymarkets-25-most-profitable-traders-75m-is-sitting-in-just-5-j8o</guid>
      <description>&lt;p&gt;Polymarket publishes a leaderboard. It also publishes every wallet's positions. Nobody joins the two, so I did — with a scraper that hits the public &lt;code&gt;data-api&lt;/code&gt; (no login, no key) and writes one flat row per whale position.&lt;/p&gt;

&lt;p&gt;Here is what the top 25 traders by 30-day PnL were holding this morning (September 4, 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;25 traders, $2.03M combined 30-day profit.&lt;/strong&gt; Median $61.5k. #1 (Sassy-Bucket) made $206k on only $203k of volume — a ~100% return; #3 (BrotherObama) needed $548k of volume for $176k.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;318 open positions worth $7.53M&lt;/strong&gt;, sitting on &lt;strong&gt;$980k of unrealized profit&lt;/strong&gt;. 188 of 318 (59%) are currently green.&lt;/li&gt;
&lt;li&gt;Average entry price 0.55, average current price 0.60. They buy at coin-flip odds and hold while the market drifts their way.&lt;/li&gt;
&lt;li&gt;Median time to resolution: &lt;strong&gt;60 days&lt;/strong&gt;. Only 28% of positions resolve within a week. These are not scalpers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the money actually is
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Positions&lt;/th&gt;
&lt;th&gt;Current value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Crypto price levels ("Will BTC dip to $X by Dec 31?")&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.50M&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Macro (Fed rate decisions)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;$1.11M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sports (MLB O/U, US Open tennis)&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;$0.52M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Politics / legislation&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;$0.17M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;td&gt;$1.24M&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One wallet (JnStrtPrdctnMrkts) owns the five largest positions on the board — all of them &lt;strong&gt;"No" on Bitcoin/Ethereum dip markets&lt;/strong&gt;: "ETH dips to $1,500 by Dec 31" No at $865k (entered 0.62, now 0.86, +$242k), "BTC dips to $55k" No at $589k (+$209k), "$50k" No at $552k, "$45k" No at $391k, "ETH $1,250" No at $370k. That is a $2.8M ladder betting that crypto simply does not crash this year, bought when the market priced the crash at 25–45%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most crowded trades
&lt;/h2&gt;

&lt;p&gt;Markets where several whales independently hold the same side are the closest thing to a consensus signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"No change in Fed rates after the September 2026 meeting" — 5 of 25 whales, all Yes, $749k.&lt;/strong&gt; Plus 3 whales on "No" to a 25 bps hike ($309k). The smart-money book is unanimous: nothing happens in September.&lt;/li&gt;
&lt;li&gt;"Clarity Act (H.R.3633) signed into law in 2026?" — 3 whales, all Yes, $108k.&lt;/li&gt;
&lt;li&gt;Sports are where they disagree: Giants–Pirates O/U 8.5 has 2 whales Over and 2 Under. Struff–Cerundolo at the US Open: 3 on Cerundolo, 2 on Struff. Sports positions are small ($5–30k) and short-dated — this looks like edge-hunting, not conviction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this is useful for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy-trading feeds&lt;/strong&gt;: schedule the scraper hourly, diff &lt;code&gt;conditionId + wallet&lt;/code&gt; against the previous run, and you get "whale opened / closed a position" alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment on macro &amp;amp; crypto&lt;/strong&gt;: whale positioning on Fed and BTC-level markets moves before the retail price does; this is the cheapest way to watch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research&lt;/strong&gt;: the &lt;code&gt;avgPrice&lt;/code&gt; vs &lt;code&gt;curPrice&lt;/code&gt; columns give you each whale's entry, so you can measure who's early and who's chasing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Two public endpoints: &lt;code&gt;GET /v1/leaderboard?window=1m&amp;amp;rankType=pnl&amp;amp;limit=25&lt;/code&gt; for the roster and &lt;code&gt;GET /positions?user=&amp;lt;wallet&amp;gt;&amp;amp;limit=100&amp;amp;sortBy=CURRENT&lt;/code&gt; per wallet. Pure HTTP, ~25 requests, 10–20 seconds for the whole board. Output per row: rank, wallet, trader name, 30-day PnL and volume, market, side, shares, avg vs current price, current value, cash PnL, % PnL, end date, and a direct market URL. A watchlist mode accepts your own wallet list, and only position rows are charged.&lt;/p&gt;

&lt;p&gt;It runs on Apify as &lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/polymarket-top-traders-scraper" rel="noopener noreferrer"&gt;Polymarket Top Traders Scraper | Whale Positions &amp;amp; Copy Feed&lt;/a&gt;&lt;/strong&gt; — schedule it, pipe it to Sheets/Slack, or call it from an MCP client. Siblings if you want the full prediction-market stack: &lt;a href="https://apify.com/tactful_anvil/polymarket-kalshi-arbitrage-scanner" rel="noopener noreferrer"&gt;Polymarket Kalshi Arbitrage Scanner&lt;/a&gt; (same market, two venues, price gap) and &lt;a href="https://apify.com/tactful_anvil/prediction-market-movers" rel="noopener noreferrer"&gt;Prediction Market Movers&lt;/a&gt; (biggest 24h probability swings on both platforms).&lt;/p&gt;

&lt;p&gt;If you run it and it saves you a spreadsheet afternoon, a review or bookmark on the Apify page helps more than you'd think.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not financial advice. Data is a snapshot; whales close positions too.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>datascience</category>
      <category>crypto</category>
      <category>api</category>
    </item>
    <item>
      <title>I scraped all 545 startups for sale on Microns. Sellers ask 4.3x revenue. Buyers pay 3.3x.</title>
      <dc:creator>Mr Zack</dc:creator>
      <pubDate>Wed, 02 Sep 2026 19:44:28 +0000</pubDate>
      <link>https://dev.to/tactful_anvil/i-scraped-all-545-startups-for-sale-on-microns-sellers-ask-43x-revenue-buyers-pay-33x-1a14</link>
      <guid>https://dev.to/tactful_anvil/i-scraped-all-545-startups-for-sale-on-microns-sellers-ask-43x-revenue-buyers-pay-33x-1a14</guid>
      <description>&lt;p&gt;Every micro-acquisition thread ends with the same question: &lt;em&gt;what multiple is fair for a tiny SaaS?&lt;/em&gt; People answer from memory. I'd rather answer from the board.&lt;/p&gt;

&lt;p&gt;So I pulled every listing on &lt;a href="https://www.microns.io" rel="noopener noreferrer"&gt;Microns&lt;/a&gt; — the marketplace where indie founders sell $1K–$100K micro-startups — as of September 3, 2026. That's &lt;strong&gt;545 listings: 390 still for sale, 155 marked sold.&lt;/strong&gt; Here's what the numbers say.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The ask/clear gap is a full turn of revenue
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Listings with revenue&lt;/th&gt;
&lt;th&gt;Median asking price&lt;/th&gt;
&lt;th&gt;Median price ÷ ARR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Available&lt;/strong&gt; (for sale now)&lt;/td&gt;
&lt;td&gt;374&lt;/td&gt;
&lt;td&gt;$10,750&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.26x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Sold&lt;/strong&gt; (market comps)&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;td&gt;$3,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.33x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sellers &lt;em&gt;ask&lt;/em&gt; 4.3x annual revenue. What actually &lt;em&gt;clears&lt;/em&gt; is 3.3x. If you're buying, the sold column is your anchor, not the listing page. If you're selling, pricing at 5x+ mostly means you'll still be listed in four months — median time on board for available listings is &lt;strong&gt;110 days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And it's not a few outliers dragging the ask up: &lt;strong&gt;181 of the 390 available listings (46%) are priced above 5x revenue.&lt;/strong&gt; Only 27 of 136 sold listings (20%) cleared at more than 10x, and those are the tiny-revenue cases where a $3K price on $200 ARR shows up as "15x".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Multiples depend on category more than on the startup
&lt;/h2&gt;

&lt;p&gt;Median &lt;em&gt;sold&lt;/em&gt; multiple by category (categories with at least 5 sold comps):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Sold comps&lt;/th&gt;
&lt;th&gt;Median multiple that cleared&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Directory&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6.75x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Micro-SaaS&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;5.33x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newsletter&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;4.20x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web App&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;3.14x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile App&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;2.95x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E-commerce&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2.08x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube channel&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1.12x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things surprised me. Directories clear at the &lt;em&gt;highest&lt;/em&gt; multiple — low maintenance, recurring, SEO-driven cash. And YouTube channels clear at barely 1x revenue: buyers price in the key-person risk. If you own a directory, you're sitting on a more sellable asset than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Where the underpriced deals are hiding
&lt;/h2&gt;

&lt;p&gt;Of 390 available listings, &lt;strong&gt;137 are priced at ≤3x revenue and 79 at ≤2x.&lt;/strong&gt; Thirty listings were added in the last 7 days. The good ones move in days — the median sold listing had a $3,000 price, which is exactly the size an indie buyer can close on a weekend.&lt;/p&gt;

&lt;p&gt;The top of my deal score right now (fresh + fairly priced + real revenue + real customers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Startup&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Asking&lt;/th&gt;
&lt;th&gt;ARR&lt;/th&gt;
&lt;th&gt;Multiple&lt;/th&gt;
&lt;th&gt;Days listed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fashion Online Store&lt;/td&gt;
&lt;td&gt;E-commerce&lt;/td&gt;
&lt;td&gt;$109,000&lt;/td&gt;
&lt;td&gt;$341,000&lt;/td&gt;
&lt;td&gt;0.32x&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Image-to-Video Generator&lt;/td&gt;
&lt;td&gt;Web App&lt;/td&gt;
&lt;td&gt;$30,000&lt;/td&gt;
&lt;td&gt;$55,933&lt;/td&gt;
&lt;td&gt;0.54x&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon FBA Decor Brand&lt;/td&gt;
&lt;td&gt;E-commerce&lt;/td&gt;
&lt;td&gt;$85,000&lt;/td&gt;
&lt;td&gt;$103,000&lt;/td&gt;
&lt;td&gt;0.83x&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relaxation Gummies Store&lt;/td&gt;
&lt;td&gt;E-commerce&lt;/td&gt;
&lt;td&gt;$36,000&lt;/td&gt;
&lt;td&gt;$38,330&lt;/td&gt;
&lt;td&gt;0.94x&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gamified Fitness / RPG Workout&lt;/td&gt;
&lt;td&gt;Micro-SaaS&lt;/td&gt;
&lt;td&gt;$19,000&lt;/td&gt;
&lt;td&gt;$18,107&lt;/td&gt;
&lt;td&gt;1.05x&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E-commerce dominates the top because revenue there is gross, not net — which is exactly why you need the seller's &lt;code&gt;expenses&lt;/code&gt; and &lt;code&gt;businessModel&lt;/code&gt; fields next to the multiple before you get excited. The dataset carries both.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I get this every morning
&lt;/h2&gt;

&lt;p&gt;I didn't do this by hand. It's an Apify Actor I maintain: &lt;strong&gt;&lt;a href="https://apify.com/tactful_anvil/microns-startup-listings-scraper" rel="noopener noreferrer"&gt;Microns Startup Listings Scraper&lt;/a&gt;&lt;/strong&gt;. One run = the whole board, HTTP-only (no browser, no login, no proxies), with these fields per listing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;askingPriceUsd&lt;/code&gt;, &lt;code&gt;arrUsd&lt;/code&gt;, and &lt;code&gt;revenueMultiple&lt;/code&gt; (null when ARR is 0 or unpublished — never a fake number)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; = &lt;code&gt;available&lt;/code&gt; or &lt;code&gt;sold&lt;/code&gt;, so the sold rows double as comps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;pricePerCustomer&lt;/code&gt;, &lt;code&gt;publishedDate&lt;/code&gt;, &lt;code&gt;daysListed&lt;/code&gt;, &lt;code&gt;foundedYear&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the seller's own &lt;code&gt;businessModel&lt;/code&gt;, &lt;code&gt;expenses&lt;/code&gt;, &lt;code&gt;growthOpportunity&lt;/code&gt;, &lt;code&gt;reasonForSelling&lt;/code&gt;, &lt;code&gt;askingPriceReasoning&lt;/code&gt;, &lt;code&gt;techStack&lt;/code&gt;, &lt;code&gt;competitors&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dealScore&lt;/code&gt; 0–100: revenue evidence + price fairness vs ~3x + traction + freshness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every run also writes a &lt;code&gt;SUMMARY&lt;/code&gt; record with board totals, available vs sold, &lt;strong&gt;median revenue multiple&lt;/strong&gt; and new-this-week count — so a weekly &lt;code&gt;listingStatus: "sold"&lt;/code&gt; run gives you a live micro-startup valuation index that nobody publishes.&lt;/p&gt;

&lt;p&gt;The daily deal-alert input I use, on a schedule with a Slack integration:&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;"listingStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minArrUsd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxRevenueMultiple"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sortBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dealScore"&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;Pay-per-result, so a filtered run like that costs cents. It's also callable from AI agents (MCP) — "find me SaaS listings under $20K priced below 3x revenue, added this week" is one tool call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, because this is money
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Revenue on Microns is self-reported. Multiples are a screening tool, not diligence.&lt;/li&gt;
&lt;li&gt;"Sold" means the seller marked it sold; the clearing price is the &lt;em&gt;listed&lt;/em&gt; price at that time, which may differ from the final negotiated number.&lt;/li&gt;
&lt;li&gt;Sample sizes per category are small (7–36 comps). Treat category medians as direction, not law.&lt;/li&gt;
&lt;li&gt;Not financial advice. Verify on the listing page before you message a founder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you buy, sell, or benchmark micro-startups and want a field added, open an Issue on the Actor page — the parser is monitored daily.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>data</category>
      <category>webscraping</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
