<?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: northbell</title>
    <description>The latest articles on DEV Community by northbell (@northbell).</description>
    <link>https://dev.to/northbell</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%2F4096874%2Fa9c13dc4-17c2-4b4f-b4ef-c187a77b28c5.png</url>
      <title>DEV Community: northbell</title>
      <link>https://dev.to/northbell</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/northbell"/>
    <language>en</language>
    <item>
      <title>I measured what my 11 Actors cost to run. The 96x spread was mostly one config field.</title>
      <dc:creator>northbell</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:23:04 +0000</pubDate>
      <link>https://dev.to/apify/i-measured-what-my-11-actors-cost-to-run-the-96x-spread-was-mostly-one-config-field-hoj</link>
      <guid>https://dev.to/apify/i-measured-what-my-11-actors-cost-to-run-the-96x-spread-was-mostly-one-config-field-hoj</guid>
      <description>&lt;p&gt;I wrote this article twice. The first version was finished, proofread, and queued to publish. Then someone asked a question about one number in it, and the thesis came apart.&lt;/p&gt;

&lt;p&gt;I am publishing the second version, along with the part where I was wrong, because the mistake turned out to be more useful than the article I meant to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I set out to do
&lt;/h2&gt;

&lt;p&gt;Pay-per-event pricing asks you to name a price per unit of value. I had priced eleven Actors that way without knowing what a unit costs me to produce.&lt;/p&gt;

&lt;p&gt;So I measured. Twenty-two runs, every Actor I have published, cost taken from the platform's own accounting rather than from an estimate. Every finished run carries &lt;code&gt;usageTotalUsd&lt;/code&gt;, every dataset carries &lt;code&gt;itemCount&lt;/code&gt;, and cost per row is the first divided by the second:&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;run&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;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/actor-runs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ds&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;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/datasets/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;costPerThousand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageTotalUsd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know before you copy it. &lt;strong&gt;On the free plan, runs and their datasets are kept for a limited window and then removed&lt;/strong&gt; — I went looking for figures from two weeks earlier and they were simply gone, so save whatever you measure outside the platform. And &lt;strong&gt;one run is not a measurement&lt;/strong&gt;: per-run overhead lands entirely on however many rows that run happened to produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table I almost published
&lt;/h2&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;cost / 1,000 rows&lt;/th&gt;
&lt;th&gt;rows per second&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bulk job listings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.008&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify app reviews&lt;/td&gt;
&lt;td&gt;$0.010&lt;/td&gt;
&lt;td&gt;5.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Store ranks&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;td&gt;7.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all jobs at one company&lt;/td&gt;
&lt;td&gt;$0.035&lt;/td&gt;
&lt;td&gt;1.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;company posts&lt;/td&gt;
&lt;td&gt;$0.059&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + applicant counts&lt;/td&gt;
&lt;td&gt;$0.105&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;company headcount&lt;/td&gt;
&lt;td&gt;$0.254&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + salary parsing&lt;/td&gt;
&lt;td&gt;$0.417&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + working filters&lt;/td&gt;
&lt;td&gt;$0.427&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Play ratings&lt;/td&gt;
&lt;td&gt;$0.508&lt;/td&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;job details from a URL (free)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.730&lt;/strong&gt;&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;$0.008 to $0.730. A &lt;strong&gt;96× spread&lt;/strong&gt;, and the most expensive one was the Actor I give away for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The story I told myself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Requests per row&lt;/strong&gt;, I wrote. That's the whole story.&lt;/p&gt;

&lt;p&gt;The cheapest Actor and one of the most expensive read the same public endpoint, on the same site, with the same library. The cheap one takes ten rows out of every request it makes. The expensive one makes one request per row, because the thing it needs — the salary text, the applicant count — only exists on the individual posting.&lt;/p&gt;

&lt;p&gt;It is a good story, and everything in the table lined up behind it. Cheap Actors ran at 5–8 rows per second, expensive ones at 0.3–0.7. Cost and throughput, the same measurement wearing different clothes, because you are billed for the time a machine is held open.&lt;/p&gt;

&lt;p&gt;I wrote twelve hundred words on that and finished with the free Actor's $0.730 as the closing irony.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that broke it
&lt;/h2&gt;

&lt;p&gt;Then someone read the draft and asked the least sophisticated question available:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"That $0.730 — is that actually fine as it is?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had treated the number as a finding. It was a bug report, and I had filed it as an anecdote.&lt;/p&gt;

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

&lt;p&gt;I set out to check whether $0.730 was affordable. I ended up checking the run settings of the four most expensive Actors instead.&lt;/p&gt;

&lt;p&gt;All four were configured for &lt;strong&gt;4096 MB&lt;/strong&gt; of memory. The other seven were at 512.&lt;/p&gt;

&lt;p&gt;The four most expensive Actors in my table were exactly the four with eight times the RAM. Not approximately — exactly.&lt;/p&gt;

&lt;p&gt;Apify bills memory × time. A container with 8× the memory costs 8× per second whether or not anything uses it. And none of these four needs it: no browser, no Playwright, one dependency (&lt;code&gt;apify&lt;/code&gt;), plain &lt;code&gt;fetch&lt;/code&gt;, and the detail lookups run in a sequential rate-limited loop. Four gigabytes to hold one HTTP response at a time.&lt;/p&gt;

&lt;p&gt;I have no memory of choosing 4096. It is the kind of value that lands in a template and then in every Actor you scaffold from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controlled test
&lt;/h2&gt;

&lt;p&gt;Same Actor, same input, three memory settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;memory&lt;/th&gt;
&lt;th&gt;cost&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;seconds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4096 MB&lt;/td&gt;
&lt;td&gt;$0.000746&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024 MB&lt;/td&gt;
&lt;td&gt;$0.000224&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.000111&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&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;Roughly linear in memory, flat in time. And on a real workload rather than a one-row sample — 51 rows, 108 seconds:&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;at 4096 MB&lt;/th&gt;
&lt;th&gt;at 512 MB&lt;/th&gt;
&lt;th&gt;throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;jobs + working filters&lt;/td&gt;
&lt;td&gt;$0.427 / 1,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.093 / 1,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.5 → 0.5 rows/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + salary parsing&lt;/td&gt;
&lt;td&gt;$0.417 / 1,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.113 / 1,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.5 → 0.5 rows/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Identical throughput. The RAM was never doing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected table
&lt;/h2&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;cost / 1,000&lt;/th&gt;
&lt;th&gt;was&lt;/th&gt;
&lt;th&gt;rows/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bulk job listings&lt;/td&gt;
&lt;td&gt;$0.008&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;5.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify app reviews&lt;/td&gt;
&lt;td&gt;$0.010&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;5.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Store ranks&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all jobs at one company&lt;/td&gt;
&lt;td&gt;$0.035&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;company posts&lt;/td&gt;
&lt;td&gt;$0.059&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + working filters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.093&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.427&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + applicant counts&lt;/td&gt;
&lt;td&gt;$0.105&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;job details from a URL (free)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.111&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.730&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs + salary parsing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.113&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.417&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Play ratings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.139&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.508&lt;/td&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;company headcount&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.254&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&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;The spread is &lt;strong&gt;33×, not 96×&lt;/strong&gt;. The free Actor is no longer the most expensive; it sits in the middle. The Actor now at the top is a genuinely one-request-per-row job, which is the only kind that belongs there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives, and what doesn't
&lt;/h2&gt;

&lt;p&gt;Requests per row is real. It is still the largest architectural driver in the table, and it accounts for the entire 33× that remains. That part of the first draft was right.&lt;/p&gt;

&lt;p&gt;But it was not "the whole story," and the ranking I built the story on was wrong. Four Actors sat at the top of my table for a reason that had nothing to do with their code — and I explained their position with an argument about their code, an argument I could support for each of them, in a paragraph.&lt;/p&gt;

&lt;p&gt;That is the uncomfortable part. The story was not weakly supported. It was well supported and wrong.&lt;/p&gt;

&lt;p&gt;A derived number does two jobs: it gives you a value, and it gives you a ranking. If one input is misconfigured, you do not get a slightly wrong value. You get a &lt;strong&gt;confidently wrong ranking&lt;/strong&gt; — and the ranking is the part you reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  And check who actually pays
&lt;/h2&gt;

&lt;p&gt;One more thing the second pass turned up.&lt;/p&gt;

&lt;p&gt;My free Actor is not monetized, which on Apify means pay-per-usage: &lt;em&gt;"Users can run the Actor without any additional charges beyond the platform usage costs generated by the Actor."&lt;/em&gt; The person who runs it pays, from their own account.&lt;/p&gt;

&lt;p&gt;So $0.730 was never my bill. It was the bill I was handing to whoever tried my free sample first — the one Actor in that table whose cost lands on a stranger, and the one I had written up as a personal irony.&lt;/p&gt;

&lt;p&gt;It is now about $0.11, and it is still free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I would check on day one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The run settings, before the code.&lt;/strong&gt; Memory and timeout are two fields. They are set once, never reviewed, and one of them is a direct multiplier on every bill you generate. Mine was wrong on four Actors and I never looked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether your number ranks or only measures.&lt;/strong&gt; If you are going to reason about which item is worst, confirm the inputs are comparable &lt;em&gt;before&lt;/em&gt; you interpret the order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who the cost lands on.&lt;/strong&gt; Free does not mean free. It means someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;List your Actors, take the recent successful runs, divide &lt;code&gt;usageTotalUsd&lt;/code&gt; by the dataset's &lt;code&gt;itemCount&lt;/code&gt;, multiply by a thousand — and print &lt;code&gt;defaultRunOptions.memoryMbytes&lt;/code&gt; as a column in the same table. That last column is the one I would add first.&lt;/p&gt;

&lt;p&gt;The free Actor is &lt;a href="https://apify.com/northbell/linkedin-job-detail-scraper" rel="noopener noreferrer"&gt;Free LinkedIn Job Scraper&lt;/a&gt; — paste a job URL, get JSON back, no login and no paid plan. It is about 4× cheaper to run than it was this morning, and that took no code change at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;northbell builds honest web scrapers on Apify. Including, evidently, honest corrections.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>javascript</category>
      <category>webscraping</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>My 11 Actors were invisible in Apify Store for a week. One query parameter proved it.</title>
      <dc:creator>northbell</dc:creator>
      <pubDate>Thu, 03 Sep 2026 01:58:19 +0000</pubDate>
      <link>https://dev.to/apify/my-11-actors-were-invisible-in-apify-store-for-a-week-one-query-parameter-proved-it-lmi</link>
      <guid>https://dev.to/apify/my-11-actors-were-invisible-in-apify-store-for-a-week-one-query-parameter-proved-it-lmi</guid>
      <description>&lt;p&gt;I published eleven Actors on Apify at the end of August. A week later they had, between them, zero users who were not me or Apify's own daily QA run.&lt;/p&gt;

&lt;p&gt;I did the things you do. I rewrote the store descriptions around the words buyers type. I published two articles under the Apify organisation on DEV. I posted in the Discord. Nothing moved, and I kept telling myself it was a discovery problem — the Actors were fine, nobody knew they existed yet.&lt;/p&gt;

&lt;p&gt;That was true. It was just not true in the way I thought. Nobody knew they existed because, for anyone searching Apify Store, they did not.&lt;/p&gt;

&lt;p&gt;This post is the diagnosis, the one API call that made it certain, the fix, and a warning for anyone publishing from outside the US or EU. It took about four hours to resolve once I knew what to ask. It took a week to know what to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that finally looked wrong
&lt;/h2&gt;

&lt;p&gt;Store search ranks by usage, so a new Actor being buried under established ones is normal. I did not expect to see mine on page one for "linkedin jobs". I did expect to see them &lt;em&gt;somewhere&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The check that broke the "just buried" story was searching for one of my own Actor slugs, exactly:&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 https://api.apify.com/v2/store?search=linkedin-company-jobs-scraper
→ 12 results. None of them mine.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make sure that was not simply what happens to small Actors, I searched the exact slug of another creator's Actor that had four users in total. It came back first, alone. So an Actor with four users is findable by its own name, and mine — public, passing QA, 98.8% run success — were not.&lt;/p&gt;

&lt;p&gt;Two more checks, both negative:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sortBy=lastUpdate&lt;/code&gt; did not include my Actors minutes after I rebuilt two of them. Whatever was hiding them was not a ranking problem.&lt;/li&gt;
&lt;li&gt;The web Store search behaved identically to the API. My profile page listed all eleven as public. The Actor pages loaded fine by direct URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Public, reachable, working, and absent from every list. That is not "buried". That is a filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parameter
&lt;/h2&gt;

&lt;p&gt;The Store endpoint has a parameter I had never read past:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;includeUnrunnableActors&lt;/strong&gt; — By default, search results exclude Actors that are not safe to run automatically (e.g. Actors from developers who haven't passed KYC, or full-permission Actors without a large user base). Set to &lt;code&gt;true&lt;/code&gt; to bypass this safety filtering and include all Actors in the results.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I ran the pair that settles it:&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 /v2/store?username=northbell
→ 0 Actors

GET /v2/store?username=northbell&amp;amp;includeUnrunnableActors=true
→ 11 Actors
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same eleven Actors appear and disappear on one flag. Nothing about indexing, nothing about ranking. The Store had them; it was choosing not to show them.&lt;/p&gt;

&lt;p&gt;The parameter's own description names two causes. All eleven of my Actors run with &lt;code&gt;LIMITED_PERMISSIONS&lt;/code&gt;, so it was not the second one. That leaves the first: I had not completed identity verification.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, take the pair of requests above. It is a ten-second test, it returns a yes or a no, and it tells you whether any amount of work on titles, READMEs, categories or promotion can help you at all. For a week, in my case, it could not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the verification actually lives
&lt;/h2&gt;

&lt;p&gt;I went looking for it in Settings. &lt;code&gt;/settings/payouts&lt;/code&gt; and &lt;code&gt;/settings/monetization&lt;/code&gt; both redirect to the account page, which is where I concluded the feature was not enabled for my account and stopped.&lt;/p&gt;

&lt;p&gt;It is under &lt;strong&gt;Actors → Insights → Payouts&lt;/strong&gt;, as a banner that says &lt;em&gt;Identity verification required&lt;/em&gt; with a &lt;em&gt;Verify Identity&lt;/em&gt; button. I only found it after a support reply mentioned "Insights – Payouts", and I had been looking at that sidebar entry for a week without opening it, because "Insights" did not sound like a place where a gate would be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why my documents failed, and why yours might
&lt;/h2&gt;

&lt;p&gt;The verification portal accepts a passport, a driving licence, or a national ID card. I am in Japan. I tried my driving licence, then my national ID card, several times each, in good light, flat, all four corners in frame. Every attempt came back rejected, with a reviewer note that the provider "does not support verification through this type of document" and a banner reading &lt;em&gt;Mismatch between the data you declared and the data from the verification&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The two messages together are the explanation. Japanese driving licences and national ID cards print the holder's name in Japanese characters only — there is no romanised name anywhere on the card. My account name is in Latin script. The automated check had nothing on the document to match it against, and reported a mismatch. In Japan the passport is essentially the only widely held ID that carries a romanised name, and I do not have one.&lt;/p&gt;

&lt;p&gt;So this was never a photography problem, and retrying with a better photo was never going to work. If your country's ID does not carry your name in the script your account uses, expect the same, and go straight to support rather than re-submitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What support did
&lt;/h2&gt;

&lt;p&gt;I wrote to support with the structural explanation above — not "it failed", but &lt;em&gt;why&lt;/em&gt; it could not succeed. The first three replies were from an AI assistant that could not answer a policy question, and said so. When I asked it to pass the case to a person, it did. A support engineer acknowledged within half an hour, asked the verification team, and about four hours after my first email I had this in the console:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Identity verified.&lt;/strong&gt; Your identity has been successfully verified. You're all set to receive payouts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I re-ran the pair of requests. &lt;code&gt;?username=northbell&lt;/code&gt; returned eleven.&lt;/p&gt;

&lt;h2&gt;
  
  
  What visibility bought, measured
&lt;/h2&gt;

&lt;p&gt;Being listed is necessary. I want to be precise about how far it is from sufficient, because the next trap looks a lot like success.&lt;/p&gt;

&lt;p&gt;An hour after verification, my Actors ranked in Store search like this:&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;rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google play rating&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin applicants&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin headcount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shopify app reviews&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin company jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;First place, with zero users, on the first day. It would have been easy to post that table and stop.&lt;/p&gt;

&lt;p&gt;But rank on a query only matters if people type the query. So for each one I added up the 30-day users of the ten Actors that rank for it — a rough measure of how many people actually arrive through that phrase:&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;my rank&lt;/th&gt;
&lt;th&gt;30-day users, top 10 combined&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google play rating&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin applicants&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin jobs salary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in top 25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31,870&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;linkedin scraper&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in top 25&lt;/td&gt;
&lt;td&gt;37,833&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I am first on phrases that a few dozen people a month use, and absent from the phrases that thirty thousand use. The phrases I win are, more or less, my own product descriptions rephrased. The phrases with traffic are held by Actors with fifteen thousand monthly users, and Store ranking weights usage heavily enough that no wording gets a new Actor past them.&lt;/p&gt;

&lt;p&gt;That is not a complaint. It is the shape of the problem now that the filter is gone: the Store shows you, but it does not bring people to you. What does is outside the Store — a Google result for a specific need, an article, a link someone shares. The Store is where they land, not where they start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I wish I had on day one
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the two requests. If &lt;code&gt;includeUnrunnableActors=true&lt;/code&gt; is the only way your Actors appear, stop everything else and go to &lt;strong&gt;Actors → Insights → Payouts&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If your ID does not carry your name in the script your Apify account uses, do not retry the portal. Write to support with the reason, ask for a human, and ask for manual verification.&lt;/li&gt;
&lt;li&gt;Once you are listed, do not read a first-place rank as demand. Add up the 30-day users of what ranks alongside you. If the total is two digits, you have won an empty room.&lt;/li&gt;
&lt;li&gt;Record the day you became visible. Everything you measured before it was measured against a wall.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My eleven Actors are at &lt;a href="https://apify.com/northbell" rel="noopener noreferrer"&gt;apify.com/northbell&lt;/a&gt;. As of the day this was written, their combined 30-day users are ten — nearly all of it Apify's QA. That number is the baseline, and it is the first one I have been able to trust.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;northbell builds honest web scrapers on Apify. If a derived number has an error margin, the margin ships with it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>debugging</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>My scraper returned 660 jobs. There were 880. Nothing in the output said so.</title>
      <dc:creator>northbell</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:36:14 +0000</pubDate>
      <link>https://dev.to/apify/my-scraper-returned-660-jobs-there-were-880-nothing-in-the-output-said-so-3ck6</link>
      <guid>https://dev.to/apify/my-scraper-returned-660-jobs-there-were-880-nothing-in-the-output-said-so-3ck6</guid>
      <description>&lt;p&gt;The first version of my LinkedIn company-jobs Actor looked like it worked. Point it at a company, get back every open role. The dataset had hundreds of rows, the fields were populated, the run finished green.&lt;/p&gt;

&lt;p&gt;It was returning about three quarters of the jobs, and a different three quarters each time.&lt;/p&gt;

&lt;p&gt;Worse, I had built change tracking on top of it — "these 12 roles opened since your last run, these 5 closed" — and that feature was manufacturing closures out of nothing. One test run reported &lt;strong&gt;113 jobs closed and 117 opened, 75 seconds apart&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nothing in the output distinguished this from a correct answer. That is the specific failure I want to write about, because "did my scrape actually finish?" turns out to be a question you can &lt;em&gt;measure&lt;/em&gt;, not just hope about — using a method that ecologists use to count fish.&lt;/p&gt;

&lt;h2&gt;
  
  
  LinkedIn's public job search does not paginate
&lt;/h2&gt;

&lt;p&gt;The public endpoint takes a &lt;code&gt;start&lt;/code&gt; offset. I assumed &lt;code&gt;start=0&lt;/code&gt; gives you jobs 1–10, &lt;code&gt;start=10&lt;/code&gt; gives you 11–20, and so on.&lt;/p&gt;

&lt;p&gt;It doesn't. It gives you &lt;em&gt;ten jobs&lt;/em&gt;. Which ten is not stable.&lt;/p&gt;

&lt;p&gt;I ran the same query twice and compared the returned job IDs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 pages deep: &lt;strong&gt;33%&lt;/strong&gt; of the IDs were different&lt;/li&gt;
&lt;li&gt;10 pages deep: &lt;strong&gt;61%&lt;/strong&gt; of the IDs were different&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a straightforward loop — walk &lt;code&gt;start&lt;/code&gt; from 0 upward until a page comes back short — does not collect a company's roster. It collects &lt;em&gt;a sample&lt;/em&gt; of it. On a small company the sample happens to be everything. On a large one it isn't, and nothing tells you which case you're in.&lt;/p&gt;

&lt;p&gt;Two things were broken by this, and only one of them was obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The obvious one:&lt;/strong&gt; change tracking. If run A samples 660 of 880 and run B samples a different 660, roughly 220 IDs are in A and not in B. Those get reported as closed. They were never closed. They were never &lt;em&gt;absent&lt;/em&gt; — they just weren't in this draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one I nearly missed:&lt;/strong&gt; the basic listing was wrong too. Not the fancy feature — the core product. I was selling "every open role at this company" and shipping a list missing a quarter of it, with no indication that anything was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two stopping rules that don't work
&lt;/h2&gt;

&lt;p&gt;My first attempt at "am I done" was the obvious heuristic: &lt;strong&gt;stop when four consecutive requests add nothing new.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's why that's not a measurement. Say the company has 700 roles and I currently hold 650. Each request returns 10 roles drawn from the pool. The chance that all ten are ones I already have:&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;// P(a page adds nothing) with 650 of 700 held, 10 drawn:&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;650&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;   &lt;span class="c1"&gt;// ≈ 0.478&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearly half. Four in a row happens about 5% of the time &lt;em&gt;at that exact point&lt;/em&gt; — and more often as you get closer. So the rule does fire eventually. It just fires at a completeness level that depends on the fraction you already hold, which is precisely the unknown you were trying to determine. The stopping rule is circular: it tells you you're done by assuming you're nearly done.&lt;/p&gt;

&lt;p&gt;My second attempt went the other way: &lt;strong&gt;collect the roster twice and only accept it if the two passes match exactly.&lt;/strong&gt; Rigorous, and useless. Two random samples of a large pool are essentially never identical. The condition never fired, so the feature never produced a number, so I had built an elaborate way of saying "I don't know."&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer was in the data I already had
&lt;/h2&gt;

&lt;p&gt;The thing that unstuck me was reframing what those repeated draws are. They aren't retries. They are &lt;strong&gt;samples&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ecologists count fish in a pond by catching some, tagging them, releasing them, then catching a second batch and seeing how many carry tags. Few tags means a big pond. That's Lincoln–Petersen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N ≈ (size of sample A × size of sample B) / (number in both)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I already had two samples — I just had to stop merging them into one bucket. So the Actor now splits its own requests into two sets, tracks them separately, and estimates the total from their overlap:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;estimateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sampleA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sampleB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sampleA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sampleB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sampleA&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;sampleB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then coverage is just what I hold divided by what I estimate exists, and "how many am I still missing" falls out of the same number.&lt;/p&gt;

&lt;p&gt;Here is a real run against a company page, 800 requests, recording the unique jobs held at each point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;requests&lt;/th&gt;
&lt;th&gt;unique jobs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;345&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;617&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;td&gt;795&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;240&lt;/td&gt;
&lt;td&gt;844&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;320&lt;/td&gt;
&lt;td&gt;867&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;878&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;520&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;880&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;520–800&lt;/td&gt;
&lt;td&gt;880 (zero new in 280 requests)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Estimated total from the overlap: &lt;strong&gt;880&lt;/strong&gt;. Actual point of exhaustion: &lt;strong&gt;880&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The pool wasn't unreachable. &lt;strong&gt;I had been stopping at 93 requests.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: I split the samples along the wrong axis
&lt;/h2&gt;

&lt;p&gt;The first version of the split used request parity — odd-numbered requests into sample A, even into sample B. The estimate came back at &lt;strong&gt;1,334&lt;/strong&gt; for a company with 880 roles. Fifty percent too high, and confidently so.&lt;/p&gt;

&lt;p&gt;The reason took me longer than it should have. The offset also advances once per request. So odd requests were looking at odd pages, and even requests at even pages. The two samples were drawing from &lt;em&gt;different parts of the pool&lt;/em&gt;, which makes their overlap unnaturally small, which inflates N. Lincoln–Petersen assumes both samples come from the same population; mine didn't.&lt;/p&gt;

&lt;p&gt;The fix is to split by &lt;strong&gt;sweep&lt;/strong&gt; — one full pass over all offsets — rather than by request:&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;sweep&lt;/span&gt; &lt;span class="o"&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;floor&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;/&lt;/span&gt; &lt;span class="nx"&gt;pages&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;start&lt;/span&gt; &lt;span class="o"&gt;=&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;%&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&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="nx"&gt;sweep&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;evenSample&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oddSample&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both samples cover every offset. The estimate landed on 880.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the formula collapses, and it collapses &lt;em&gt;quietly&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;This one reached production before I caught it, and it produced the worst kind of output: a confident, plausible, wrong number.&lt;/p&gt;

&lt;p&gt;The two samples were 20 and 681 items, with an overlap of 20. Run the formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N = 20 × 681 / 20 = 681
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The estimate equals the count I already had. Coverage: 100%. The Actor concluded it had collected everything, stopped, compared against the previous run, and reported &lt;strong&gt;199 jobs closed&lt;/strong&gt;. None had closed.&lt;/p&gt;

&lt;p&gt;When one sample is entirely contained in the other, the formula returns the larger sample. It isn't an error — it's what the math says when your "second sample" is really just a subset of the first. And an overlap of 20 comfortably passes a naive "overlap must be at least 10" check.&lt;/p&gt;

&lt;p&gt;So the guards aren't about size, they're about &lt;em&gt;shape&lt;/em&gt;:&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;MIN_SAMPLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&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_BALANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// smaller ÷ larger&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_SAMPLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;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;sample-too-small&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_BALANCE&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;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;samples-unbalanced&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;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;overlap-too-small&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;Plus one more condition outside the function: don't trust an estimate until &lt;strong&gt;both samples have completed at least one full sweep&lt;/strong&gt;. The degenerate case above happened when the run stopped after one sweep plus two requests, leaving one sample with two requests' worth of data.&lt;/p&gt;

&lt;p&gt;Note what &lt;code&gt;total: null&lt;/code&gt; means here. It is not zero and it is not 100%. It means &lt;em&gt;I cannot tell&lt;/em&gt;, and it travels to the output with a reason attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: I fixed the wrong variable
&lt;/h2&gt;

&lt;p&gt;My first instinct on the collapse was to raise the minimum sample size from 20 to 50. Bigger samples, less degeneracy, done.&lt;/p&gt;

&lt;p&gt;That broke companies with about 25 open roles. Too many to fit in one page (10 per request), too few to ever reach a 50-item sample. &lt;strong&gt;Mid-sized companies became undecidable&lt;/strong&gt; — the Actor could neither confirm nor estimate, so it withheld everything.&lt;/p&gt;

&lt;p&gt;The collapse was never caused by absolute size. It was caused by &lt;em&gt;imbalance&lt;/em&gt; — 20 versus 681. Raising the floor treated a symptom and disabled a whole class of correct cases. So the absolute minimum went back down to 20, and the real protection lives in the balance ratio and the two-sweep rule.&lt;/p&gt;

&lt;p&gt;Small companies get a separate path entirely, based on a fact rather than an estimate: if no response ever filled a full page, there is no second page, and what I have is everything. Measured page churn at one page deep is 0%, so this is safe — and it matters, because mark-and-recapture needs an overlap of 10+ to work at all, which means &lt;strong&gt;the smaller the company, the less able it is to estimate&lt;/strong&gt;. Exactly backwards, if you don't handle it separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: my own request cap looked like a wave of layoffs
&lt;/h2&gt;

&lt;p&gt;A production run reported &lt;code&gt;closed: 20&lt;/code&gt;. Nothing had closed. I had changed the per-company cap from 30 to 20 between runs.&lt;/p&gt;

&lt;p&gt;Truncate a list, compare it to an untruncated one, and the rows you didn't collect appear as rows that vanished. Same for changing a filter: narrow the location, and everything outside it "closes."&lt;/p&gt;

&lt;p&gt;So before any comparison, the Actor now checks whether the two observations are comparable at all, and refuses to produce numbers when they aren't:&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;blank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;truncated-this-run&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;prevPoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;blank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;previous-run-was-truncated&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterKey&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prevPoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;blank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filters-changed&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;&lt;code&gt;opened&lt;/code&gt; and &lt;code&gt;closed&lt;/code&gt; come back as &lt;code&gt;null&lt;/code&gt; with a reason, instead of numbers that look real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Actor reports now
&lt;/h2&gt;

&lt;p&gt;Every company row carries the measurement alongside the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;openJobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;880&lt;/span&gt;
&lt;span class="na"&gt;estimatedTotal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;880&lt;/span&gt;
&lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;estimatedMissing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;changeMarginOfError&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;collectionComplete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;requestsUsed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;523&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;changeMarginOfError&lt;/code&gt; is the one I'd argue hardest for. If this run missed &lt;em&gt;m&lt;/em&gt; roles and the previous run missed &lt;em&gt;m'&lt;/em&gt;, the apparent change is muddied by up to &lt;em&gt;m + m'&lt;/em&gt;. A run at 99% coverage on 880 roles is missing about 9 — so a reported change of ±18 is inside the noise. That is why the target coverage for change tracking defaults higher than the target for just listing jobs. A number without its error bar invites a conclusion it can't support.&lt;/p&gt;

&lt;p&gt;One more measurement, since it costs nothing to state: LinkedIn returns HTTP 400 for &lt;code&gt;start&lt;/code&gt; values of 1000 and above. In an 800-request experiment, exactly 20 requests failed — 1000, 1010, … 1190 — and every other request succeeded. The offsets below 1000 are enough, because each pass returns a different draw. Circling 0–990 repeatedly reaches everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"No new results" is not evidence of completeness.&lt;/strong&gt; It's evidence about the fraction you already hold, which is the thing you were trying to measure. Any stopping rule built on it is circular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeated requests are samples, not retries.&lt;/strong&gt; The moment I stopped merging them into one bucket, the data I already had answered the question I thought I needed a new experiment for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A degenerate formula returns a number, not an error.&lt;/strong&gt; &lt;code&gt;20 × 681 / 20 = 681&lt;/code&gt; is arithmetically fine and semantically garbage. If a formula has a collapse mode, guard its &lt;em&gt;shape&lt;/em&gt; — and make the guard return "I don't know" rather than a default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silence and completeness are indistinguishable unless you report the difference.&lt;/strong&gt; Before this work, "660 jobs" and "880 jobs" looked identical from the outside. Now the row says which one it is, and how sure it is.&lt;/p&gt;

&lt;p&gt;The estimator, the guards and the tests that pin down each of these four bugs are at &lt;a href="https://github.com/northbell-dev/honest-scraping" rel="noopener noreferrer"&gt;northbell-dev/honest-scraping&lt;/a&gt; — no dependencies, &lt;code&gt;npm test&lt;/code&gt; runs the 40 tests with nothing to install.&lt;/p&gt;

&lt;p&gt;The Actor is &lt;a href="https://apify.com/northbell/linkedin-company-jobs-scraper" rel="noopener noreferrer"&gt;LinkedIn Company Jobs Scraper&lt;/a&gt;. It reads only public pages — no login, no cookies, and the request headers are a frozen object that cannot carry one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;northbell builds honest web scrapers on Apify. If a derived number has an error margin, the margin ships with it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>datascience</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>I scraped 89 job postings to find out how my salary parser was lying to me</title>
      <dc:creator>northbell</dc:creator>
      <pubDate>Tue, 01 Sep 2026 10:50:34 +0000</pubDate>
      <link>https://dev.to/apify/i-scraped-89-job-postings-to-find-out-how-my-salary-parser-was-lying-to-me-51kh</link>
      <guid>https://dev.to/apify/i-scraped-89-job-postings-to-find-out-how-my-salary-parser-was-lying-to-me-51kh</guid>
      <description>&lt;p&gt;I shipped a LinkedIn jobs scraper that returns salary as structured numbers — &lt;code&gt;salaryMin&lt;/code&gt;, &lt;code&gt;salaryMax&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, and a yearly figure so an hourly warehouse job and a director role sit on the same axis. The whole point was to make "show me jobs paying over $150k" a question you can actually ask.&lt;/p&gt;

&lt;p&gt;Then I ran it against 89 real postings and found six separate ways it was wrong.&lt;/p&gt;

&lt;p&gt;None of them threw an error. Every single one produced a confident-looking number. That is the part worth writing about: a salary parser doesn't fail loudly, it fails &lt;em&gt;plausibly&lt;/em&gt;, and the only way to find out is to sit down with real postings and check them one by one.&lt;/p&gt;

&lt;p&gt;Here's what I found, in the order I found it, including the one where the platform had already solved my problem and I broke it by "fixing" it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why job descriptions are hostile to salary parsing
&lt;/h2&gt;

&lt;p&gt;My first assumption was that the salary would live in a structured field. It doesn't.&lt;/p&gt;

&lt;p&gt;I pulled 64 postings across eight roles and countries — software engineer in the US, registered nurse in California, retail associate in Texas, truck driver, marketing manager in New York, software developer in the UK, data analyst in Canada, accountant in the US — and looked for where the money actually was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The top-card salary box: &lt;strong&gt;3 out of 64&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;JSON-LD &lt;code&gt;baseSalary&lt;/code&gt;: &lt;strong&gt;0 out of 64&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the salary is in the free-text description, mixed in with every other dollar figure a company might mention. And companies mention a lot of dollar figures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"We're a Series A company with $20M+ raised from Headline, Village Global"
"serving an $800 billion market"
"New Hire Bonus: $3,000 after 90 days"
"[$5,000] signing bonus available to external hires"
"Annual maximum out of pocket $3,000 per individual / $7,500 per family"
"we have grown from $1 million in sales in 1983 to more than $500 million today"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not one of those is a salary. A naive regex that grabs &lt;code&gt;$[\d,]+&lt;/code&gt; returns all of them.&lt;/p&gt;

&lt;p&gt;My first version had a plausibility gate for exactly this reason: normalize any candidate to a yearly figure first, and if it lands outside &lt;strong&gt;$8,000–$2,000,000&lt;/strong&gt;, it isn't a wage. That one rule kills &lt;code&gt;$20M raised&lt;/code&gt;, &lt;code&gt;$800 billion market&lt;/code&gt;, and &lt;code&gt;$1 million in sales&lt;/code&gt; instantly, because nobody is paid $800 billion a year.&lt;/p&gt;

&lt;p&gt;It also killed a real salary, which is where the trouble started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: the bonus in the next sentence poisoned the salary
&lt;/h2&gt;

&lt;p&gt;Here's a real posting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Salary Range: $46.00 - $50 per hour   New Hire Bonus: $3,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My parser rejected it. Not the bonus — the &lt;em&gt;salary&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The reason: I was testing my noise words (&lt;code&gt;bonus&lt;/code&gt;, &lt;code&gt;signing&lt;/code&gt;, &lt;code&gt;raised&lt;/code&gt;, &lt;code&gt;market&lt;/code&gt;) against a window of context around the candidate. &lt;code&gt;$46.00 - $50 per hour&lt;/code&gt; had "New Hire Bonus" sitting 8 characters to its right, so the whole match got thrown out.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating context as a blob and start treating it as directional. A number's &lt;em&gt;label&lt;/em&gt; is what comes before it. What comes after belongs to the next number:&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;// Drop it only if this number's own label is noise.&lt;/span&gt;
&lt;span class="c1"&gt;// Whatever sits on the right belongs to the next number, not this one.&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;NOISE_BEFORE&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;left&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;NOISE_BEFORE&lt;/code&gt; is anchored to the end of the left context:&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;NOISE_BEFORE&lt;/span&gt; &lt;span class="o"&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;bonus|signing|sign&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;on|relocation|referral|stipend|allowance|raised|funding|valuation|revenue|market|worth|in&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;sales|arr&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|series&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;a-e&lt;/span&gt;&lt;span class="se"&gt;])\b[\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;*$/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 &lt;code&gt;$&lt;/code&gt; anchor is the whole trick. "Bonus:" immediately before a number means that number is a bonus. "Bonus" thirty characters later means nothing about this number at all.&lt;/p&gt;

&lt;p&gt;Once I made that change, the pay range came back and the $3,000 bonus stayed rejected — which is exactly right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a following word's first letter became a magnitude suffix
&lt;/h2&gt;

&lt;p&gt;This one was invisible until I checked a number I had no reason to suspect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1st year compensation plans range from $55,000-$95,000 Medical, dental, life
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parser said: no salary found.&lt;/p&gt;

&lt;p&gt;I instrumented the candidate loop and printed what it actually matched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw=|$55,000-$95,000 M|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. My money regex ended with &lt;code&gt;[kKmM]?&lt;/code&gt; to catch &lt;code&gt;$150K&lt;/code&gt; and &lt;code&gt;$20M&lt;/code&gt;. The word after the range was &lt;strong&gt;M&lt;/strong&gt;edical. The regex ate its first letter as a magnitude suffix, read &lt;code&gt;$95,000 M&lt;/code&gt; as ninety-five &lt;em&gt;billion&lt;/em&gt;, and the plausibility gate — correctly, given what it was handed — threw the whole thing away.&lt;/p&gt;

&lt;p&gt;A parser that turns &lt;code&gt;$95,000&lt;/code&gt; into &lt;code&gt;$95,000,000,000&lt;/code&gt; and then silently drops the job is worse than one that crashes. The job just quietly isn't in your results.&lt;/p&gt;

&lt;p&gt;The fix is a negative lookahead: a &lt;code&gt;k&lt;/code&gt; or &lt;code&gt;m&lt;/code&gt; only counts as a suffix if a letter doesn't follow it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// k/m is a magnitude suffix only when it follows the digits AND no letter follows it,&lt;/span&gt;
&lt;span class="c1"&gt;// so "95,000 Medical" keeps its M.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NUM_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="s2"&gt;`\d[\d,]*(?:\.\d+)?\s?[kKmM]?(?![A-Za-z])`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regression test, because I never want to relearn this:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;regression: does not eat a following word initial M/K as a suffix ($95,000 Medical)&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="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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compensation plans range from $55,000-$95,000 Medical, dental&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;salaryFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;salaryMax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;95000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// must not read "$95,000 M" as 95,000,000,000&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base pay $80,000 Monthly meetings and $18/hr overtime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;salaryFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// does not eat the M of "Monthly"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bugs 3 and 4: ranges that collapsed to a single number
&lt;/h2&gt;

&lt;p&gt;Two postings looked fine in the output. They weren't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Salary Range: $81,000 USD - $105,000 USD    →  min 81000, max 81000
expected hiring range is $36.79/hr - $58.50/hr  →  min 36.79, max 36.79
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both reported the bottom of the range as if it were the whole range. If you filter on "pays at least $100k", the first job is wrongly excluded. Quiet, plausible, wrong.&lt;/p&gt;

&lt;p&gt;The cause was the same in both cases: my range pattern was positional, &lt;code&gt;NUMBER separator NUMBER&lt;/code&gt;, and both postings put something between the first number and the separator — a currency word in one, a unit in the other. The pattern stopped at the first number and treated it as a lone value.&lt;/p&gt;

&lt;p&gt;I could have kept patching the pattern with more optional groups. Instead I stopped matching positions and started counting numbers:&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;// Take at most two numbers out of raw. A positional range regex breaks as soon as a unit&lt;/span&gt;
&lt;span class="c1"&gt;// or currency word sits between them ("$X/hr - $Y/hr", "$X USD - $Y"), so extract the&lt;/span&gt;
&lt;span class="c1"&gt;// numbers instead and let the count decide.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&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[\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;(?:\.\d&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;kKmM&lt;/span&gt;&lt;span class="se"&gt;])?(?![&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;toNumber&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="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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&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;Two numbers inside one money token means a range. One means a single value. It stopped mattering what was sitting between them.&lt;/p&gt;

&lt;p&gt;That refactor immediately exposed bug 4: &lt;code&gt;C$90,000 - C$110,000&lt;/code&gt; also collapsed, because a leftover separator check didn't recognise &lt;code&gt;C$&lt;/code&gt; after the dash. Deleting the check fixed it — the tokenizer already guaranteed what the check was testing for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: the insurance benefit that looked like a director's salary
&lt;/h2&gt;

&lt;p&gt;An electrician job came back at &lt;strong&gt;$200,000 a year&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Electricians in that market make about $80k. The parser had found this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Annual maximum out of pocket $3,000 per individual / $7,500 per family
... Health Insurance (In-network) $1,000 individual deductible
... Life Insurance plan with coverage up to $200,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word &lt;strong&gt;Annual&lt;/strong&gt; was doing the damage. My period detector saw it, concluded "yearly", and the plausibility gate happily accepted $200,000 as a yearly wage. It is a plausible salary. It is not this job's salary — it's an insurance payout ceiling.&lt;/p&gt;

&lt;p&gt;The fix was another directional rule, this time for benefits vocabulary:&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;// Benefits and insurance words disqualify a number only when they sit on the LEFT,&lt;/span&gt;
&lt;span class="c1"&gt;// the label side. "out of pocket $200,000" -&amp;gt; drop it.&lt;/span&gt;
&lt;span class="c1"&gt;// "salary $120,000 plus health insurance" -&amp;gt; the label is salary, so keep it.&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;BENEFIT_NEAR&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;left&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote it as a both-sides check first and caught myself: &lt;code&gt;base salary $120,000 plus health insurance and 401k match&lt;/code&gt; is an extremely normal sentence, and a both-sides rule throws that real salary away. Left-side only keeps it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 6: "40 hours per week" is not a pay period
&lt;/h2&gt;

&lt;p&gt;A dental hygienist posting listed both a full-time salary and a part-time rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Full time hours-40 hours per week salary-$60,428.52.
Part-time-work up to 29 hours per week, $45/hour.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser returned the part-time hourly rate and ignored the full-time salary entirely.&lt;/p&gt;

&lt;p&gt;Why: &lt;code&gt;$60,428.52&lt;/code&gt; had "hours &lt;strong&gt;per week&lt;/strong&gt;" sitting in its left context. My period detector read &lt;code&gt;per week&lt;/code&gt;, called it a weekly wage, multiplied by 52, got $3.1M, and the plausibility gate threw it out. The part-time rate survived, so the output looked complete.&lt;/p&gt;

&lt;p&gt;Schedule language and pay-period language use the same words. So I strip schedule phrases before reading the period:&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;// A work schedule ("40 hours per week", "5 days a week") is not a pay period, so remove it&lt;/span&gt;
&lt;span class="c1"&gt;// from the context first. Leaving it in makes the $60,428 in "hours per week salary-$60,428"&lt;/span&gt;
&lt;span class="c1"&gt;// look like a weekly wage.&lt;/span&gt;
&lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;hours&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|hrs&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|days&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|shifts&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;|per&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;|a&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;|an&lt;/span&gt;&lt;span class="se"&gt;\s)\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;week|day|month|hour|year&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/gi&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, the full-time salary came back at $60,429/year — and &lt;code&gt;Base pay $800 per week&lt;/code&gt;, a genuine weekly wage, still parses as weekly. Both are in the test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong about being wrong
&lt;/h2&gt;

&lt;p&gt;I want to be honest about a seventh thing, because it wasn't a parser bug — it was me.&lt;/p&gt;

&lt;p&gt;Separately from the salary work, I was making my Actors usable as tools for AI agents through Apify's MCP server. I compared my input schemas against what the MCP &lt;code&gt;tools/list&lt;/code&gt; response exposed, and noticed that &lt;code&gt;required&lt;/code&gt; didn't appear in the tool definitions. Three Actors, same result. I concluded the agent never learns which fields are mandatory, edited six Actors to prepend "Required." to those field descriptions, and deployed all six.&lt;/p&gt;

&lt;p&gt;Then I looked at the description text an agent actually receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**REQUIRED**&lt;/span&gt; LinkedIn job URLs (https://www.linkedin.com/jobs/view/...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apify's MCP server already injects &lt;code&gt;**REQUIRED**&lt;/code&gt; into the description of required fields. The information was reaching the agent the whole time. My "fix" produced &lt;code&gt;**REQUIRED** Required. LinkedIn job URLs...&lt;/code&gt; on three live Actors before I caught it. I reverted all six and redeployed.&lt;/p&gt;

&lt;p&gt;The mistake wasn't the edit. It was that I checked the &lt;em&gt;field I expected to carry the information&lt;/em&gt; instead of the &lt;em&gt;payload the consumer actually receives&lt;/em&gt;. Which is the same mistake as bug 5, one level up: I trusted a signal (&lt;code&gt;Annual&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;) without checking what it meant in context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the parser refuses to do
&lt;/h2&gt;

&lt;p&gt;The finished version outputs a number only when it can defend it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Normalize first, judge second.&lt;/strong&gt; Everything becomes a yearly figure before the plausibility gate. Hourly × 2080, monthly × 12. &lt;code&gt;$800B market&lt;/code&gt; never reaches the output because $800B/year is not a wage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A number needs its own label.&lt;/strong&gt; A bare &lt;code&gt;$600,000&lt;/code&gt; in a sentence about project value is not pay. Something must mark it: a unit (&lt;code&gt;per hour&lt;/code&gt;, &lt;code&gt;/yr&lt;/code&gt;), a pay word before it (&lt;code&gt;salary&lt;/code&gt;, &lt;code&gt;pay range&lt;/code&gt;, &lt;code&gt;compensation&lt;/code&gt;), or a period word in context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direction matters.&lt;/strong&gt; Labels sit to the left. Words to the right belong to the next number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it can't tell, it says so.&lt;/strong&gt; &lt;code&gt;salaryFound: false&lt;/code&gt; and no invented figure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And every run reports the fill rate, because the honest answer to "why did I only get 74 jobs" is usually "because 6 in 10 US postings state pay, and the rest didn't write a number down":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;examined: 12   withSalary: 8 (67%)   returned&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;span class="na"&gt;droppedBelowMin: 1   droppedNoSalary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
&lt;span class="na"&gt;annualSalaryMedian: 250000   range&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;135000-350000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 67% matched my earlier measurement of 63% across a different sample, which was the first thing that made me trust the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building something similar
&lt;/h2&gt;

&lt;p&gt;Three things I'd do from the start next time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the corpus before the parser.&lt;/strong&gt; I spent an afternoon collecting money phrases with their surrounding context from eight different job types. Nurses and retail workers are quoted hourly, engineers yearly, accountants monthly, and each format breaks a different assumption. If I had only tested on software jobs I would have shipped four of these six bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Print what your regex actually matched.&lt;/strong&gt; Bug 2 was undiagnosable from the output — the field was just empty. One line dumping &lt;code&gt;raw=|$55,000-$95,000 M|&lt;/code&gt; made it obvious in seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the regression test in the same commit as the fix.&lt;/strong&gt; All six are in the suite. When I later replaced the range logic entirely, those tests caught a fifth bug I'd introduced in the process, before it shipped.&lt;/p&gt;

&lt;p&gt;The parser and every regression test in this post are at &lt;a href="https://github.com/northbell-dev/honest-scraping" rel="noopener noreferrer"&gt;northbell-dev/honest-scraping&lt;/a&gt; — no dependencies, &lt;code&gt;npm test&lt;/code&gt; runs the 40 tests with nothing to install.&lt;/p&gt;

&lt;p&gt;The Actor is &lt;a href="https://apify.com/northbell/linkedin-jobs-salary-scraper" rel="noopener noreferrer"&gt;LinkedIn Jobs Salary Data&lt;/a&gt;. It reads only public pages — no login, no cookies, and the request headers are a frozen object that cannot carry one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;northbell builds honest web scrapers on Apify. If a derived number has an error margin, the margin ships with it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>regex</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
