<?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>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>
