<?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: Dodo Data</title>
    <description>The latest articles on DEV Community by Dodo Data (@dododata).</description>
    <link>https://dev.to/dododata</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%2F4131094%2F564579c3-ede5-444e-ab8d-a0d831527f6f.png</url>
      <title>DEV Community: Dodo Data</title>
      <link>https://dev.to/dododata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dododata"/>
    <language>en</language>
    <item>
      <title>What is actually in the We Work Remotely RSS feed, and four things that will trip your parser</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Mon, 05 Oct 2026 21:54:34 +0000</pubDate>
      <link>https://dev.to/dododata/what-is-actually-in-the-we-work-remotely-rss-feed-and-four-things-that-will-trip-your-parser-7ih</link>
      <guid>https://dev.to/dododata/what-is-actually-in-the-we-work-remotely-rss-feed-and-four-things-that-will-trip-your-parser-7ih</guid>
      <description>&lt;p&gt;We Work Remotely publishes its jobs as RSS, and says so plainly on its RSS page: "Anyone can use the feed, all we ask is that you attribute the links back to We Work Remotely." That makes it one of the few big job boards you can build on without a terms-of-service argument. We built a tool on it at the end of September, and this is what the feed contains once you read all of it, measured on 25 September 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twelve feeds, 253 jobs
&lt;/h2&gt;

&lt;p&gt;There is one feed of the newest jobs, &lt;code&gt;https://weworkremotely.com/remote-jobs.rss&lt;/code&gt;, and one per category, listed on the RSS page. The newest-jobs feed held 80 jobs. All twelve together held &lt;strong&gt;253 distinct jobs&lt;/strong&gt;, because each category feed reaches further back than the newest-jobs feed does.&lt;/p&gt;

&lt;p&gt;The same job appears in several of them. A Full-Stack job is in the newest-jobs feed, in the Full-Stack feed and in the "All Programming" feed, so if you read everything, de-duplicate on the job link. The newest-jobs feed even listed one job twice on the day we looked.&lt;/p&gt;

&lt;p&gt;One feed was empty: Front-End Programming had no items at all. An empty feed is a valid answer, not an error, so do not treat zero items as a failure. Treat a response that is not RSS at all as the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  An item, and what each field really means
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;item&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Headway: Senior Manager, Content &lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt; Community Marketing&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;region&amp;gt;&lt;/span&gt;Anywhere in the World&lt;span class="nt"&gt;&amp;lt;/region&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;country&amp;gt;&lt;/span&gt;🇺🇸 United States of America&lt;span class="nt"&gt;&amp;lt;/country&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;state&amp;gt;&lt;/span&gt;California&lt;span class="nt"&gt;&amp;lt;/state&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;skills&amp;gt;&lt;/span&gt;Community Growth, Digital Marketing, and Marketing&lt;span class="nt"&gt;&amp;lt;/skills&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;category&amp;gt;&lt;/span&gt;Sales and Marketing&lt;span class="nt"&gt;&amp;lt;/category&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;type&amp;gt;&lt;/span&gt;Full-Time&lt;span class="nt"&gt;&amp;lt;/type&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;description&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;p&lt;span class="ni"&gt;&amp;amp;gt;&amp;amp;lt;&lt;/span&gt;strong&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;Headquarters:&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;/strong&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt; ...&lt;span class="nt"&gt;&amp;lt;/description&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;pubDate&amp;gt;&lt;/span&gt;Fri, 07 Aug 2026 18:48:42 +0000&lt;span class="nt"&gt;&amp;lt;/pubDate&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;expires_at&amp;gt;&lt;/span&gt;Tue, 06 Oct 2026 18:48:42 +0000&lt;span class="nt"&gt;&amp;lt;/expires_at&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;guid&amp;gt;&lt;/span&gt;https://weworkremotely.com/remote-jobs/headway-senior-manager-content-community-marketing&lt;span class="nt"&gt;&amp;lt;/guid&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&amp;gt;&lt;/span&gt;https://weworkremotely.com/remote-jobs/headway-senior-manager-content-community-marketing&lt;span class="nt"&gt;&amp;lt;/link&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;title&lt;/code&gt; is two fields.&lt;/strong&gt; It is the company and the job title joined by a colon: "Headway: Senior Manager, Content &amp;amp; Community Marketing". Split at the first &lt;code&gt;": "&lt;/code&gt;, not the last, because job titles contain colons too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;region&lt;/code&gt; says almost nothing.&lt;/strong&gt; It was "Anywhere in the World" on 245 of the 253 jobs, including jobs whose &lt;code&gt;country&lt;/code&gt; lists one country. When the two disagree, &lt;code&gt;country&lt;/code&gt; is the one the job page shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;country&lt;/code&gt; is a list with flags in it.&lt;/strong&gt; Several countries come joined by commas and "and":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🇨🇦 Canada and 🇺🇸 United States of America
🇨🇺 Cuba, 🇺🇸 United States of America, and 🇷🇺 Russian Federation
🇬🇧 United Kingdom of Great Britain and Northern Ireland and 🇺🇸 United States of America
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splitting on " and " cuts the United Kingdom in half, and so does Bosnia and Herzegovina. Every country starts with a flag, which is two Unicode regional-indicator characters, so split only where the next flag begins:&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;countries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;,|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;and&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;(?=\p&lt;/span&gt;&lt;span class="sr"&gt;{Regional_Indicator}&lt;/span&gt;&lt;span class="se"&gt;{2})&lt;/span&gt;&lt;span class="sr"&gt;/u&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;c&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;c&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;\p&lt;/span&gt;&lt;span class="sr"&gt;{Regional_Indicator}/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="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;[&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; is not where the job is.&lt;/strong&gt; It looks like a US state for a remote job, but it reads as the employer's address: one job listed France, Japan, Mexico, Norway, Türkiye and Viet Nam as its countries, with the state "California". The job page does not show it at all, which is the best hint about what it is. We leave it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;description&lt;/code&gt; holds two more fields.&lt;/strong&gt; It is entity-escaped HTML, and it always starts with a paragraph WWR writes itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;Headquarters:&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt; Bridgetown, Saint Michael, Barbados
&lt;span class="nt"&gt;&amp;lt;br&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;URL:&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"http://thehivecareers.co"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;http://thehivecareers.co&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you the employer's website on 124 of the 253 jobs, and a headquarters text on 243. Read what the employer typed there as written: for jobs imported from an applicant tracking system it is often a place of work instead, like "REMOTE - US".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;skills&lt;/code&gt; is filled on about a third of jobs&lt;/strong&gt; (83 of 253 on 25 September), as a comma list. Most lists fit the job, but some do not: a sales development role lists "Architect, Architecture, Bug Tracking", and a marketing internship lists "Infrastructure Orchestration" and "Endpoint Security". Keep it as its own field and do not search on it, or a search for security engineers will return a marketing intern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The older feeds are thinner.&lt;/strong&gt; The "All Programming" feed carries no &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt; or &lt;code&gt;expires_at&lt;/code&gt;. The newest-jobs feed and the per-category feeds do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pay is in the text, and reading it is where we got it wrong
&lt;/h2&gt;

&lt;p&gt;The feed has no pay field. 78 of the 253 descriptions state a range next to a word like Salary or Compensation, so we read it out with the same parser our other job tools use. Then we read all of the results by hand against their text. Two were wrong, and both bugs were in code already running in public tools.&lt;/p&gt;

&lt;p&gt;The first was a currency: &lt;code&gt;Compensation: US$65-$85&lt;/code&gt; came out as Singapore dollars. The table of currency codes was checked in order, Singapore's &lt;code&gt;S$&lt;/code&gt; came before &lt;code&gt;US$&lt;/code&gt;, and &lt;code&gt;S$&lt;/code&gt; is sitting inside &lt;code&gt;US$&lt;/code&gt;. The short symbols now only count as a whole token:&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="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/SGD|&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&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-Za-z&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;S&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SGD&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;The second was a period: "$42-60,000 USD Annual Salary ($3500-$5000" was read as a yearly salary of 3,500 to 5,000 dollars. The first range was skipped because it was malformed, and the second borrowed the word "Annual" from it. In dollars, pounds or euros no full-time yearly salary is under 10,000, so a range that small is now never called a year's pay. It is left out, because a wrong salary is worse than none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run that saved nothing
&lt;/h2&gt;

&lt;p&gt;The first run of the finished tool on Apify returned 0 of 253 rows. The platform rejected every write with "Schema validation failed", because two fields in the dataset schema had been copied from a sibling tool where they are always empty and typed as &lt;code&gt;null&lt;/code&gt;, and this one fills them. The feed was read perfectly and nothing was kept.&lt;/p&gt;

&lt;p&gt;The fix took a minute. The lesson took a test: rows parsed from real feed items are now checked against the dataset schema before anything is deployed, so a mismatch fails on a laptop instead of on the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/weworkremotely-jobs-scraper" rel="noopener noreferrer"&gt;We Work Remotely Jobs Scraper&lt;/a&gt; on Apify Store reads all twelve feeds and nothing else, de-duplicates them, and returns each job as JSON in 39 fields, with the countries, the employer's website, pay read out of the text and a link back to the job on weworkremotely.com, which is the one thing WWR asks for. The whole feed took 15 seconds. A monitor mode returns only the jobs you have not had yet. $1 per 1,000 jobs.&lt;/p&gt;

&lt;p&gt;It returns the same core fields as our &lt;a href="https://apify.com/dododata/remote-jobs-search" rel="noopener noreferrer"&gt;Remote Jobs Search&lt;/a&gt;, which reads Himalayas, Remote OK, Jobicy and Arbeitnow, so the two can go in one table.&lt;/p&gt;

</description>
      <category>rss</category>
      <category>webscraping</category>
      <category>node</category>
      <category>api</category>
    </item>
    <item>
      <title>Our job scraper was nearly 5x slower and costlier because of a proxy it never needed</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Mon, 05 Oct 2026 21:52:27 +0000</pubDate>
      <link>https://dev.to/dododata/our-job-scraper-was-nearly-5x-slower-and-costlier-because-of-a-proxy-it-never-needed-4fbp</link>
      <guid>https://dev.to/dododata/our-job-scraper-was-nearly-5x-slower-and-costlier-because-of-a-proxy-it-never-needed-4fbp</guid>
      <description>&lt;p&gt;We built a tool that takes company names and returns their open jobs: type &lt;code&gt;Databricks&lt;/code&gt;, &lt;code&gt;OpenAI&lt;/code&gt;, &lt;code&gt;Lyra Health&lt;/code&gt;, get their postings as JSON. Behind it is an index of more than 500 companies whose job boards on Greenhouse, Lever and Ashby we had proven belong to them (how, and why guessing gets it wrong half the time, is in &lt;a href="https://dev.to/dododata/half-the-ats-job-boards-you-find-by-guessing-belong-to-a-different-company-861"&gt;an earlier post&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The first runs on the real platform found three problems that no unit test had. All three returned HTTP 200, and all three had been sitting in tools that were already public.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The first big company took the whole budget
&lt;/h2&gt;

&lt;p&gt;Input: three companies, 60 jobs. Output: 60 jobs, all from Databricks. OpenAI and Lyra Health got nothing.&lt;/p&gt;

&lt;p&gt;Nothing was broken in the usual sense. The boards were read in parallel, and every row that matched was billed and saved until the cap was hit. Databricks has 884 open jobs and answered first, so it filled the 60 before the others had spoken.&lt;/p&gt;

&lt;p&gt;If someone names three companies, they expect three companies. The fix is the same one we use when reading four job boards at once: every company gets an equal share of the budget until all of them have answered, and what does not fit waits.&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;share&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxItems&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalBoards&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedBy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;share&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;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;leftovers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version of the hand-out was a plain round-robin over the held rows, one per company per turn. A test caught it: with two companies and 6 rows it gave &lt;strong&gt;5 to 1&lt;/strong&gt;, because the big company had already delivered its 3 and still got another turn. The held rows now go to whichever company has received the fewest so far. Three companies, 60 jobs: &lt;strong&gt;20, 20, 20&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A 34 MB answer to "list the jobs"
&lt;/h2&gt;

&lt;p&gt;Lever's public postings API returns a company's entire board in one response unless you ask for pages. For most companies that is fine. For TSMG, with 4,334 open jobs, it is &lt;strong&gt;34 MB and 28.6 seconds&lt;/strong&gt; from a fast connection, past the 30-second request timeout, so the request was retried and failed. The four biggest Lever boards in the index timed out on every run we made.&lt;/p&gt;

&lt;p&gt;Lever supports &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;skip&lt;/code&gt;. We tried 250 per page first, and it was still wrong: Palantir's descriptions are long enough that 250 postings are 4.8 MB, and six of those downloading at once timed out. At 100 per page every request is under about 2 MB, and a board is read page by page, stopping once it has contributed its share.&lt;/p&gt;

&lt;p&gt;One detail if you add paging to anything that tracks what disappeared since the last run: a first page is not the whole board. We record each page, and mark a board complete only on its last page, so a board read part-way never reports its unread jobs as closed.&lt;/p&gt;

&lt;p&gt;While in there we checked Lever's &lt;code&gt;robots.txt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: *
Allow: /
Crawl-delay: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing enforced that. We had a global cap of five requests a second, which is not the same thing when several Lever companies are read in parallel. Now requests are spaced per host, and the slot is reserved before the wait, so parallel requests queue instead of racing:&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;at&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;nextSlot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&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;nextSlot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gap&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;at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The proxy we did not need
&lt;/h2&gt;

&lt;p&gt;With the company list empty, the tool searches every company in the index. &lt;code&gt;data engineer&lt;/code&gt; plus &lt;code&gt;remote&lt;/code&gt;, 60 jobs: correct results, &lt;strong&gt;9 minutes 36 seconds&lt;/strong&gt;, and more compute than 60 rows earn at $1 per 1,000.&lt;/p&gt;

&lt;p&gt;We guessed at the cause twice and were wrong twice.&lt;/p&gt;

&lt;p&gt;First guess: concurrency. Reading 20 boards at a time instead of 5 took it from 12.6 minutes to 10.5. Better, not fixed. Second guess: payload size. Greenhouse sends every full description unless you ask it not to, and Databricks alone is 10.4 MB with descriptions and 0.76 MB without. So we changed filtered searches to read the light list, filter on title, place and dates, and open only the matching jobs, 12 KB each. That was worth doing. It saved about a minute.&lt;/p&gt;

&lt;p&gt;Then we read the log properly. The average request took &lt;strong&gt;8.1 seconds&lt;/strong&gt;. The light 0.76 MB lists download in about one second from a laptop. And there were 54 retries.&lt;/p&gt;

&lt;p&gt;Every request went through a rotating proxy, because that is the default on the platform and because "use a proxy" is what scraping advice says. But these are public job-board APIs, published by the companies for their own career pages. Nobody was blocking us. One run with the proxy off:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Same search, same code&lt;/th&gt;
&lt;th&gt;Through the proxy&lt;/th&gt;
&lt;th&gt;Direct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;9 min 36 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 min 00 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute units&lt;/td&gt;
&lt;td&gt;0.160&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.034&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average request&lt;/td&gt;
&lt;td&gt;8.1 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.3 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 or 403&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The named-company run went from 81 seconds to 6. The same default had been on our public Greenhouse, Lever, Ashby, Workday and multi-system job tools. It is off now, with the switch still there for anyone who does get blocked. On pay-per-event pricing the compute is ours to pay, and one small run of each measured a two-to-eight-fold cut in what a customer run costs us.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell ourselves a week ago
&lt;/h2&gt;

&lt;p&gt;Measure before you optimise, and measure the thing you are about to change. We spent two rounds tuning concurrency and payloads for a slowdown whose cause was one line in the input defaults, visible in the first log we should have read.&lt;/p&gt;

&lt;p&gt;And "use a proxy" is advice for sites that block you. For an API that is published to be read, it is a toll you pay on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/company-jobs-api" rel="noopener noreferrer"&gt;Company Jobs API&lt;/a&gt; on Apify Store: type company names, get their open jobs from 510+ companies whose boards are verified, or search all of them at once. 30 fields per job, pay as numbers, a monitor mode that bills only new jobs. $1 per 1,000.&lt;/p&gt;

&lt;p&gt;For any company outside the index, the &lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;Career Site Jobs Scraper&lt;/a&gt; reads a career page on 12 systems by URL; there are also single-system versions for &lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;, &lt;a href="https://apify.com/dododata/lever-jobs-scraper" rel="noopener noreferrer"&gt;Lever&lt;/a&gt;, &lt;a href="https://apify.com/dododata/ashby-jobs-scraper" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt; and &lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;. Public endpoints only, no personal data in any row.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>performance</category>
      <category>node</category>
      <category>api</category>
    </item>
    <item>
      <title>The same run returned 200 rows or 104, depending on which API answered first</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:34:59 +0000</pubDate>
      <link>https://dev.to/dododata/the-same-run-returned-200-rows-or-104-depending-on-which-api-answered-first-435d</link>
      <guid>https://dev.to/dododata/the-same-run-returned-200-rows-or-104-depending-on-which-api-answered-first-435d</guid>
      <description>&lt;p&gt;Four remote job boards publish free JSON feeds: Himalayas, Remote OK, Jobicy and Arbeitnow. No keys. Pulling all four into one shape is a small job, and it produced the most interesting bug I have written this year, because the same code with the same input returned either 200 rows or 104 and the difference was which server answered first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, which is the part that causes it
&lt;/h2&gt;

&lt;p&gt;The customer asks for 200 rows. Four sources. The obvious implementation is to page each one until you have 200, and the obvious implementation is wrong: Himalayas answers fast and reports more than a hundred thousand jobs, so it fills the entire budget before the other three have finished their handshake. You ask for four boards and you get one board.&lt;/p&gt;

&lt;p&gt;So you give each board an equal share of the budget and hold it there until every board has spoken. Once all four have answered, the cap lifts and whoever has rows left can fill the remainder. Fast sources stop crowding out slow ones, and nobody's rows are wasted.&lt;/p&gt;

&lt;p&gt;That logic is right. The mistake was in what happens while a board waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&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;ok&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;waitForShare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// polls for 10 seconds&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&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="c1"&gt;// caller reads false as "stop paging"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;waitForShare&lt;/code&gt; polled for ten seconds and returned &lt;code&gt;false&lt;/code&gt;. The caller treated &lt;code&gt;false&lt;/code&gt; as stop, and dropped its cursor.&lt;/p&gt;

&lt;p&gt;The comment above that function said the board "waits rather than abandoning its cursor". It waited, and then it abandoned it. The comment described the intent and the code described the timeout, and nobody had put the two sentences next to each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it passed the first time
&lt;/h2&gt;

&lt;p&gt;I ran it, got 200 rows of 200, four boards represented, zero failures, and moved on.&lt;/p&gt;

&lt;p&gt;That run passed by luck of ordering. Remote OK answered at 08:23:31, five seconds &lt;strong&gt;before&lt;/strong&gt; Himalayas requested its first page at 08:23:36. The share was already open by the time anything could wait on it, so nothing ever waited and the broken branch never ran.&lt;/p&gt;

&lt;p&gt;Four hours later, same input, same build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;12:35:28, Himalayas reaches its 50-row share and starts waiting.&lt;/li&gt;
&lt;li&gt;12:35:38, ten seconds are up. It gives up and drops its cursor.&lt;/li&gt;
&lt;li&gt;12:35:45, Remote OK finally answers on its retry and the share opens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seven seconds.&lt;/strong&gt; The run ended with 104 rows of the 200 asked for, and the status line said &lt;code&gt;0 failures&lt;/code&gt;, because nothing had failed. Everything succeeded. It just stopped early.&lt;/p&gt;

&lt;p&gt;That is the shape worth carrying away from this post. A pipeline that fans out to several sources has a failure mode that none of the usual signals can see: one source returning nothing looks exactly like one source having nothing, and the run is a success either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fixes, and the sizing one is the general lesson
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Size the ceiling against the dependency's worst case, not its typical case.&lt;/strong&gt; Ten seconds was picked from how long Remote OK usually takes. Across four measured runs it answered at 21s, 32s, 84s and 5s, the 84-second one after two retries. A timeout whose entire job is to outlast a slow dependency has to be sized against the tail. I raised it to 90 seconds and that was too thin: the very next run measured an 84-second wait. It held, with six seconds to spare, which is not a margin, it is a coin that landed the right way up. It is 150 now.&lt;/p&gt;

&lt;p&gt;The longer ceiling costs a normal run nothing, because the poll is reactive at 250ms rather than sleeping the full duration. If you are tempted to keep a short timeout for safety, notice what that safety buys: it converts a slow run into a quietly incomplete one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A source that fails for good has to release the gate.&lt;/strong&gt; The cap lifts when every board has answered, so a board that never answers holds it shut forever and stalls the three that did. The crawler already routed permanently-failed requests to an &lt;code&gt;onFailed&lt;/code&gt; hook; this code simply never listened to it. Any barrier that waits on N participants needs a path for a participant that is never going to arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the wait does expire, warn and name the board.&lt;/strong&gt; A short run is now never silent about it. This is the cheapest of the three fixes and probably the most valuable, because it turns the next occurrence of any of this into a log line instead of an investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller ones from the same Actor
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Retrying a 429 is answering "slow down" with "no".&lt;/strong&gt; Arbeitnow answers 429 when you page it quickly. The first version retried four times per run, against a free API, from a shared address. A 429 now stops that board for the run, paging is capped at eight pages, and pages are spaced 1.1 seconds apart.&lt;/p&gt;

&lt;p&gt;I did that fix for Arbeitnow only, and left the other three boards retrying, because at the time only Arbeitnow had ever answered 429. Two days later Himalayas answered 429 and was retried four times. &lt;strong&gt;When you fix a policy for one participant, check whether it is a policy or a special case.&lt;/strong&gt; It is now one decision applied to all four, with 429 separated from 403 and 503: a rate limit stops that board, a block stays retryable behind a fresh address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A timestamp with no zone on it is UTC.&lt;/strong&gt; Jobicy sends &lt;code&gt;2026-09-22T03:45:06+00:00&lt;/code&gt;, and a parser has to survive the day that timestamp arrives bare, as &lt;code&gt;2026-09-22 03:45:06&lt;/code&gt;. Parsed with the platform default, a bare timestamp is correct on a server running UTC and four hours wrong on my laptop in Mauritius, which means it would have shipped: the CI machine and the production machine would both have agreed, and only the developer's own runs would have been wrong. Caught by a unit test that pinned the expected instant, not by any run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obligation that lives in the code
&lt;/h2&gt;

&lt;p&gt;Remote OK asks for a followed link back and a mention as the source. Jobicy asks that application buttons send the user to the original job URL. Both are met by pointing &lt;code&gt;apply_url&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; at the board's own job page and carrying &lt;code&gt;source_name&lt;/code&gt; on every row.&lt;/p&gt;

&lt;p&gt;There is a comment above that line telling the next person not to "improve" &lt;code&gt;apply_url&lt;/code&gt; into the employer's own form, because that would look like a better field and would break Jobicy's terms. If you consume a free feed, the terms are part of the schema. Write them where someone editing the field will read them.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://apify.com/dododata/remote-jobs-search" rel="noopener noreferrer"&gt;Remote Jobs Scraper and API&lt;/a&gt; on Apify Store: all four boards in one 35-field shape, de-duplicated across them, with UTC offsets where the board publishes them. $1 per 1,000 rows.&lt;/p&gt;

&lt;p&gt;Same core fields as the rest of what I publish (this one adds five, timezone offsets among them), so a pipeline over several of them needs no branching: &lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;ATS jobs across twelve systems&lt;/a&gt;, &lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;, &lt;a href="https://apify.com/dododata/lever-jobs-scraper" rel="noopener noreferrer"&gt;Lever&lt;/a&gt;, &lt;a href="https://apify.com/dododata/ashby-jobs-scraper" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;, &lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;. Public endpoints only, contact details stripped from every row.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>node</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The paging token an API hands you is not always the one it accepts</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Sun, 27 Sep 2026 02:49:26 +0000</pubDate>
      <link>https://dev.to/dododata/the-paging-token-an-api-hands-you-is-not-always-the-one-it-accepts-28nl</link>
      <guid>https://dev.to/dododata/the-paging-token-an-api-hands-you-is-not-always-the-one-it-accepts-28nl</guid>
      <description>&lt;p&gt;Workable runs the hiring pages for a large number of companies, and the search on &lt;code&gt;jobs.workable.com&lt;/code&gt; is served by a public JSON endpoint that its own site calls. No key, no OAuth. One GET gets you a page of jobs across every company on the platform, which at the time I measured it was 168,324 jobs, 48,183 of them remote and 18,048 posted in the last seven days.&lt;/p&gt;

&lt;p&gt;Writing the client took an afternoon. Finding out what it was quietly doing wrong took three days, and every one of the four problems below returned HTTP 200 the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The token changes its name in transit
&lt;/h2&gt;

&lt;p&gt;The response ends like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"nextPageToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eyJmcm9tIjoyMH0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you send it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/jobs?query=data+engineer&amp;amp;limit=20&amp;amp;nextPageToken=eyJmcm9tIjoyMH0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you get page one. Again. Forever.&lt;/p&gt;

&lt;p&gt;The parameter is only accepted as &lt;code&gt;pageToken&lt;/code&gt;. Send it under the name the response gave it and the field is ignored, the request is valid, and you are served the first page with a 200 and a fresh token that also does nothing.&lt;/p&gt;

&lt;p&gt;Here is the part worth internalising: &lt;strong&gt;a row count cannot see this.&lt;/strong&gt; Ask for 120 rows at 20 per page and you get 120 rows. Six requests, six pages, no failures, and every number in your log looks correct. What you have is the same twenty jobs six times.&lt;/p&gt;

&lt;p&gt;The check that finds it is identity, not volume:&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;first&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;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;second&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;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextPageToken&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;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobs&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;j&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;id&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;second&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;overlap&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;j&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page 2 is page 1&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;Any time you page an API you did not write, assert that page two contains an id page one did not. It is two lines and it is the only thing standing between you and a dataset that is one page wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A filter that is ignored looks exactly like a filter that matched everything
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;workplace=remote&lt;/code&gt; works. &lt;code&gt;remote=true&lt;/code&gt; does not exist, and sending it is not an error. You get back the unfiltered corpus, 200, no warning, and if you are searching a term where most jobs happen to be remote you will not notice for a week.&lt;/p&gt;

&lt;p&gt;Same family: &lt;code&gt;limit&lt;/code&gt; above 20 is a 400, which at least tells you. The silent one is worse than the loud one.&lt;/p&gt;

&lt;p&gt;The habit I have settled on is to test every filter by its complement. Run the search with the filter and without it. If the two row counts are identical, the filter did nothing, whatever the documentation says. That takes one extra request and it is the difference between a filter and a decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A cursor can hand you the same job twice, and you will bill for it twice
&lt;/h2&gt;

&lt;p&gt;On a 120-row run I got 119 distinct ids. One job was the last row of page 3 and the first row of page 4, identical in every field except the timestamp my own code stamped on it.&lt;/p&gt;

&lt;p&gt;This one matters more than it looks, because I sell these rows at a price per row. A duplicate is not an aesthetic problem, it is a customer paying twice for one job. There is no version of that which is acceptable, and a cursor over a corpus that is being written to while you read it will always be able to do it.&lt;/p&gt;

&lt;p&gt;The failure in my code was subtle and worth naming, because it is a shape that recurs. I already had a deduplicating guard. It lived on a change tracker, and the change tracker was only constructed when the customer had asked for monitoring:&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;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emittedThisRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOURCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a plain search there was no tracker, so there was no guard, and the code path that ran most often was the one with no protection at all. The guard now stands on its own, built every run:&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;seenIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I found it I checked the whole catalogue for the same shape rather than only fixing the one Actor, because a guard attached to an optional feature is a guard that is off by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A field can be filled in the schema and empty in every row
&lt;/h2&gt;

&lt;p&gt;The last one I found this week, two days before the thing shipped.&lt;/p&gt;

&lt;p&gt;Two fields in my row, &lt;code&gt;team&lt;/code&gt; and &lt;code&gt;experience_level&lt;/code&gt;, are hardcoded to &lt;code&gt;null&lt;/code&gt;. That is deliberate: this endpoint publishes neither, and the keys stay so that rows from this source have the same shape as rows from the career-page scrapers, where the ATS does publish them. Fine.&lt;/p&gt;

&lt;p&gt;What was not fine was how they were described. The schema said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;experience_level&lt;/code&gt;: Seniority when the ATS provides it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that as a buyer. It says sometimes. It is never. And the sentence did a second, worse thing: I have a script that reports how often each field is actually filled and excludes fields the schema documents as conditional, and "when the ATS provides it" is exactly the phrasing it treats as conditional. The one check I had built to catch hollow fields was being silenced by a sentence that was not true.&lt;/p&gt;

&lt;p&gt;Both now read "Always null for this Actor", and say why the key is there.&lt;/p&gt;

&lt;p&gt;If you publish data, the coverage of a field is part of its documentation. "String or null" tells the reader nothing. Null on 100% of rows, and here is the reason, tells them everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread running through all four
&lt;/h2&gt;

&lt;p&gt;None of these four showed up as an error. Every run was a 200 with the right number of rows. What caught them was checking a different thing each time: that page two held new ids, that a filter changed the count, that ids were distinct, that fields were filled.&lt;/p&gt;

&lt;p&gt;Status tells you the request worked. It has never once told you the data was right.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://apify.com/dododata/job-search-api" rel="noopener noreferrer"&gt;Jobs API&lt;/a&gt; on Apify Store: search company career pages by words, location and remote, with none of the above left as an exercise. 31 fields per job, pay parsed out of the description where the employer writes it in prose, and a monitor mode that returns only jobs that are new or changed since your last run. $1 per 1,000 rows.&lt;/p&gt;

&lt;p&gt;The endpoint is public and robots.txt allows &lt;code&gt;/api/&lt;/code&gt;, which is what this calls. It disallows the &lt;code&gt;/search&lt;/code&gt; HTML pages, which this never requests, and it carries &lt;code&gt;Content-Signal: search=yes, ai-input=yes, ai-train=no&lt;/code&gt;, which is respected.&lt;/p&gt;

&lt;p&gt;If you would rather reach one company's board directly: &lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;, &lt;a href="https://apify.com/dododata/lever-jobs-scraper" rel="noopener noreferrer"&gt;Lever&lt;/a&gt;, &lt;a href="https://apify.com/dododata/ashby-jobs-scraper" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;, &lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;, or the &lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;one that detects which system a career page runs on&lt;/a&gt;. Public endpoints only, no personal data in any row.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>javascript</category>
      <category>jobs</category>
    </item>
    <item>
      <title>Half the ATS job boards you find by guessing belong to a different company</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:34:52 +0000</pubDate>
      <link>https://dev.to/dododata/half-the-ats-job-boards-you-find-by-guessing-belong-to-a-different-company-861</link>
      <guid>https://dev.to/dododata/half-the-ats-job-boards-you-find-by-guessing-belong-to-a-different-company-861</guid>
      <description>&lt;p&gt;If you are building anything on job data, sooner or later you want a map from company name to job board. Greenhouse boards live at &lt;code&gt;boards-api.greenhouse.io/v1/boards/&amp;lt;slug&amp;gt;/jobs&lt;/code&gt;, Lever at &lt;code&gt;api.lever.co/v0/postings/&amp;lt;slug&amp;gt;&lt;/code&gt;, Ashby at &lt;code&gt;api.ashbyhq.com/posting-api/job-board/&amp;lt;slug&amp;gt;&lt;/code&gt;. All three are public, documented, and need no key.&lt;/p&gt;

&lt;p&gt;The obvious move is to guess the slug from the company name. Lowercase it, strip the punctuation, try it.&lt;/p&gt;

&lt;p&gt;It works about a quarter of the time. And &lt;strong&gt;half of what it finds belongs to a different company.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I measured this while building an index across 2,127 companies, and the verification step turned out to matter more than the discovery step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version, and what it costs you
&lt;/h2&gt;

&lt;p&gt;Take "Airspace Link". Strip it down and you get three candidates: &lt;code&gt;airspacelink&lt;/code&gt;, &lt;code&gt;airspace-link&lt;/code&gt;, &lt;code&gt;airspace&lt;/code&gt;. The third one hits: &lt;code&gt;boards-api.greenhouse.io/v1/boards/airspace/jobs&lt;/code&gt; returns 15 open jobs, HTTP 200, real data.&lt;/p&gt;

&lt;p&gt;It is not Airspace Link's board.&lt;/p&gt;

&lt;p&gt;Here is the same failure four times over, all from one 40-company sample:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;slug that returned jobs&lt;/th&gt;
&lt;th&gt;actually theirs?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Airspace Link&lt;/td&gt;
&lt;td&gt;&lt;code&gt;greenhouse/airspace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICON plc&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ashby/icon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sanctuary Computer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ashby/sanctuary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dispatch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;greenhouse/dispatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one is a generic first token that some other company owns. A job index built this way does not merely have gaps. It confidently attributes one employer's open roles to another, and nothing in the response tells you.&lt;/p&gt;

&lt;p&gt;Across the first 654: &lt;strong&gt;26% of companies had a findable board, and only 47% of those survived verification.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification is harder than it looks, because the three APIs are not equal
&lt;/h2&gt;

&lt;p&gt;The obvious check is to compare the company name against the board's company name. That works on exactly one of the three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greenhouse&lt;/strong&gt; publishes it. &lt;code&gt;GET boards-api.greenhouse.io/v1/boards/&amp;lt;slug&amp;gt;&lt;/code&gt; returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dispatch Technologies, Inc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Ashby&lt;/strong&gt; does not. The entire payload is &lt;code&gt;{"jobs": [...], "apiVersion": "..."}&lt;/code&gt; and a job looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;address, applyUrl, department, descriptionHtml, descriptionPlain,
employmentType, id, isListed, isRemote, jobUrl, location,
publishedAt, secondaryLocations, team, title, workplaceType
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No organisation name. No company field. The closest thing is &lt;code&gt;team&lt;/code&gt;, which on the board I was checking said "Construction".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lever&lt;/strong&gt; does not either. Its fields are &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;categories&lt;/code&gt;, &lt;code&gt;hostedUrl&lt;/code&gt;, &lt;code&gt;applyUrl&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; and friends. &lt;code&gt;categories&lt;/code&gt; gives you commitment, department, location and team. No company.&lt;/p&gt;

&lt;p&gt;So for two of the three systems, the board itself will never tell you whose it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I wrote first was circular, and passed everything
&lt;/h2&gt;

&lt;p&gt;My first verification compared the company name against the &lt;code&gt;hostedUrl&lt;/code&gt; in the response. For &lt;code&gt;jobs.lever.co/jumpcloud/...&lt;/code&gt; and company "JumpCloud", that matches.&lt;/p&gt;

&lt;p&gt;Of course it matches. The URL contains the slug I just guessed. The test was asking whether the slug I guessed was the slug I guessed.&lt;/p&gt;

&lt;p&gt;It reported 8 of 8 confirmed. Every one of the four false positives above sailed through it.&lt;/p&gt;

&lt;p&gt;If you are writing this kind of check, the question to ask is: &lt;strong&gt;could this test ever fail?&lt;/strong&gt; If the evidence is derived from the thing you are testing, it cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works: match a job you have already seen
&lt;/h2&gt;

&lt;p&gt;The signal that is available on all three systems is the job list itself. If you already know one real job at that company, from any other source, check whether it is on the board.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-z0-9]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;known_titles&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;board_titles&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# this is their board
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what caught all four. &lt;code&gt;greenhouse/airspace&lt;/code&gt; has 15 jobs and not one of them is the backend role we had seen at Airspace Link.&lt;/p&gt;

&lt;p&gt;Two things to know about it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It gives false negatives, and you must not treat those as proof of anything.&lt;/strong&gt; &lt;code&gt;greenhouse/shopmy&lt;/code&gt; failed the job match, and its board is literally called "ShopMy". The remote board we sourced titles from was listing a role the ATS board no longer carried. So use the name when the ATS gives you one, and fall back to the job match when it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neither signal firing means you do not know.&lt;/strong&gt; That is a third state, not a no. Those go in a candidates file, not the index, and a later pass with more known titles for that company can promote one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller things that cost me time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;api.lever.co&lt;/code&gt; publishes &lt;code&gt;Crawl-delay: 1&lt;/code&gt;.&lt;/strong&gt; My first throttle was a single global 0.15s pause, which sent Lever roughly four requests a second against the one second it asks for. Worth checking the exact host you are hitting, not the one that looks similar: &lt;code&gt;jobs.ashbyhq.com/robots.txt&lt;/code&gt; disallows &lt;code&gt;/api/&lt;/code&gt;, but &lt;code&gt;api.ashbyhq.com&lt;/code&gt; is a different host and serves no robots.txt at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filter the company list before you probe it.&lt;/strong&gt; I measured a 67% hit rate on a sample, then watched it collapse to about 10% on the full run. The sample had been filtered to engineering roles, which is exactly the population that runs an ATS. Most employers on a remote job board are staffing and VA agencies that never had a Greenhouse board to find. Filtering first removed roughly 6,700 requests that were only ever going to 404 on somebody else's API, which is both faster and more polite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that got past all of it
&lt;/h2&gt;

&lt;p&gt;Everything above was in place, and a board named &lt;code&gt;NATIONAL&lt;/code&gt; still ended up in the index three times over: as National Church Residences, as National Laboratory of the Rockies, and as National Louis University. Same for a board called &lt;code&gt;Paradigm&lt;/code&gt; and one called &lt;code&gt;Public&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The name check was doing containment in both directions. Any board whose name is a common word swallows every company that starts with it. Worse, it re-admitted &lt;code&gt;greenhouse/airspace&lt;/code&gt; for Airspace Link, which the job match had already disproved, because "Airspace" is inside "Airspace Link".&lt;/p&gt;

&lt;p&gt;Containment is the wrong test. &lt;strong&gt;One name has to lead with the other.&lt;/strong&gt; That single change is the difference between these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Board name&lt;/th&gt;
&lt;th&gt;Same company?&lt;/th&gt;
&lt;th&gt;Containment says&lt;/th&gt;
&lt;th&gt;Prefix says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dispatch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dispatch Technologies, Inc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domino Data Lab&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Domino&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beam&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Bridge to Enter Advanced Mathematics (BEAM)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;td&gt;no match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote&lt;/td&gt;
&lt;td&gt;&lt;code&gt;General Assembly Remote Jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;match&lt;/td&gt;
&lt;td&gt;no match&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more things came out of that round:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slug is evidence on its own.&lt;/strong&gt; &lt;code&gt;greenhouse/dominodatalab&lt;/code&gt; is not a guess that happened to land, it is the company name spelled out. Trusting an exact slug recovered nine boards a stricter pass had wrongly thrown away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a consistency check that does not care how a row was confirmed.&lt;/strong&gt; A board belongs to one company. If two companies both claim it, at most one is right and you cannot tell which, so demote all of them. That backstop is what caught &lt;code&gt;NATIONAL&lt;/code&gt;, and it would have caught it no matter which rule let it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;264 verified boards, 20,929 open jobs, from 2,127 companies. Every row carries which evidence confirmed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Help Scout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ats"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ashby"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"helpscout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_jobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"job_match"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confirmed_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"directorofproductdesign"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;148 confirmed on the board's own name, 94 on a job match, 13 on both, 9 on an exact slug. 104 companies sit in the candidates file. Final audit: zero boards claimed by two companies, zero short-slug matches outside the company name, zero rows without evidence.&lt;/p&gt;

&lt;p&gt;Twelve percent of the companies I started with. The other 88% either have no board on these three systems or could not be proven, and saying so is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the job data without building this
&lt;/h2&gt;

&lt;p&gt;The same three endpoints, with the row shape and the ATS handling already done, are on Apify Store:&lt;br&gt;
&lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;, &lt;a href="https://apify.com/dododata/lever-jobs-scraper" rel="noopener noreferrer"&gt;Lever&lt;/a&gt;, &lt;a href="https://apify.com/dododata/ashby-jobs-scraper" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;, &lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;, or the &lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;multi-system one&lt;/a&gt; that auto-detects which system a career page runs on. Public endpoints only, no personal data, $1 per 1,000 jobs.&lt;/p&gt;

&lt;p&gt;If you build your own instead, the one thing I would take from this post is the third state. A board you found but cannot prove is not a board you found.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
      <category>jobs</category>
    </item>
    <item>
      <title>I measured what ranks an Apify Actor. It is not your title.</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:39:05 +0000</pubDate>
      <link>https://dev.to/dododata/i-measured-what-ranks-an-apify-actor-it-is-not-your-title-1dla</link>
      <guid>https://dev.to/dododata/i-measured-what-ranks-an-apify-actor-it-is-not-your-title-1dla</guid>
      <description>&lt;p&gt;I published eight Actors on Apify Store in five days. Careful titles, real READMEs written from real runs, dataset schemas, published task examples, honest pay-per-event pricing. Then I searched the Store for the exact thing I had built and could not find myself.&lt;/p&gt;

&lt;p&gt;Not "found low". Not found at all in the first fifty results.&lt;/p&gt;

&lt;p&gt;So I stopped guessing and measured it. The answer is one number, it is not the number I had been optimising, and the whole week of title tuning was wasted effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that looked like proof
&lt;/h2&gt;

&lt;p&gt;Earlier in the week I renamed an Actor and watched it vanish from search, then reappear a few hours later at positions 12, 7 and 26 for three related terms. Another Actor appeared at position 10 for its main keyword three hours after I published it.&lt;/p&gt;

&lt;p&gt;Rename, then rank. Publish, then rank. Obvious: Store search matches on title.&lt;/p&gt;

&lt;p&gt;That was wrong, and the shape of the error is worth naming. I had two events and an interpretation, and I never ran the control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control
&lt;/h2&gt;

&lt;p&gt;Search the Store for &lt;code&gt;greenhouse jobs&lt;/code&gt;. Look at who is above me.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#    users30  actor
1       114   fantastic-jobs/greenhouse-jobs-api
...
9         0   vnx0/greenhouse-jobs-scraper
13        0   skootle/greenhouse-jobs
15        0   charliemorrisondev/greenhouse-jobs-scraper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three Actors at positions 9, 13 and 15 with &lt;strong&gt;zero users in the last thirty days&lt;/strong&gt;. My &lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;dododata/greenhouse-jobs-scraper&lt;/a&gt; has the same name, a longer README, a dataset schema, published task examples and a working price, and it is at &lt;strong&gt;74&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Identical names. Sixty places apart. Title match cannot explain that.&lt;/p&gt;

&lt;p&gt;The difference is in a field I had not been looking at, because the Store UI shows you the monthly number:&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;position&lt;/th&gt;
&lt;th&gt;users, 30 days&lt;/th&gt;
&lt;th&gt;users, &lt;strong&gt;lifetime&lt;/strong&gt;
&lt;/th&gt;
&lt;th&gt;runs, lifetime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vnx0/greenhouse-jobs-scraper&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;236&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skootle/greenhouse-jobs&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;charliemorrisondev/greenhouse-jobs-scraper&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dododata/greenhouse-jobs-scraper&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;74&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The correlation
&lt;/h2&gt;

&lt;p&gt;One request per term, then Spearman's rho between result position and each stat over the first thirty to forty results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spearman&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;d2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&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="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/store?search=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;greenhouse jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;limit=40&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;inv_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&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="n"&gt;lifetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalUsers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;monthly&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalUsers30Days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;spearman&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inv_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lifetime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;spearman&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inv_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monthly&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Search term&lt;/th&gt;
&lt;th&gt;lifetime users&lt;/th&gt;
&lt;th&gt;lifetime runs&lt;/th&gt;
&lt;th&gt;30-day users&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;greenhouse jobs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.934&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.645&lt;/td&gt;
&lt;td&gt;+0.111&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;app store reviews&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.930&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.864&lt;/td&gt;
&lt;td&gt;+0.692&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shopify products&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.871&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.574&lt;/td&gt;
&lt;td&gt;+0.387&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rank is cumulative distinct users, all time. The thirty-day number that the UI puts in front of you barely registers.&lt;/p&gt;

&lt;p&gt;This also explains the two events that fooled me. A rename does not change your rank; it briefly removes you from the index and then puts you back where you always were. And an Actor that "appeared at position 10" appeared there for a keyword where the whole field was near zero, so two lifetime users was enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that means if you are new
&lt;/h2&gt;

&lt;p&gt;The metric never decays. That cuts both ways.&lt;/p&gt;

&lt;p&gt;It is a ratchet, so every real user you ever get is permanent rank equity. It is also a wall, because the people above you banked theirs months ago and keep them for free. Paging the search API to the end, here is where five days of work actually put me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;the Actor&lt;/th&gt;
&lt;th&gt;my rank&lt;/th&gt;
&lt;th&gt;lifetime runs of the lowest Actor in the top 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;workday jobs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;workday-jobs-scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;greenhouse jobs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/dododata/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;greenhouse-jobs-scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shopify products&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/dododata/shopify-products-scraper" rel="noopener noreferrer"&gt;shopify-products-scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;app store reviews&lt;/td&gt;
&lt;td&gt;&lt;a href="https://apify.com/dododata/app-store-reviews-scraper" rel="noopener noreferrer"&gt;app-store-reviews-scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;174&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You cannot fake it either, and not only because faking things on a platform that counts them is a bad way to run a business. &lt;code&gt;totalUsers&lt;/code&gt; counts &lt;em&gt;distinct users&lt;/em&gt;, so running your own Actor a thousand times moves nothing.&lt;/p&gt;

&lt;p&gt;The practical conclusion is uncomfortable and clarifying at the same time. On-platform discovery for a new account is a closed loop: search is how people find you, search is ordered by usage, and usage comes from search. Your first users cannot come from the Store. They have to come from somewhere else, and then the Store starts working for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one surface that is not gated on usage
&lt;/h2&gt;

&lt;p&gt;There is exactly one: &lt;code&gt;sortBy=newest&lt;/code&gt;. It orders by publication date and nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.apify.com/v2/store?sortBy=newest&amp;amp;limit=100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My newest Actor was at &lt;strong&gt;position 17&lt;/strong&gt; four hours after publishing, while the seven older ones sat at 728 to 745. Measuring the age of the Actor at each depth gives the decay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;th&gt;Age of the Actor there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;17 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;301&lt;/td&gt;
&lt;td&gt;33 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;701&lt;/td&gt;
&lt;td&gt;61 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About six new Actors an hour, platform-wide. So publishing buys you roughly three hours on the first page of &lt;code&gt;newest&lt;/code&gt; and about a day in the top hundred.&lt;/p&gt;

&lt;p&gt;Three hours is not much. But it is the only window a zero-history account gets, and it has a scheduling consequence I had completely backwards: &lt;strong&gt;never publish two Actors on the same day.&lt;/strong&gt; I had three finishing staging together and was going to ship them in one batch. That would have spent three windows to buy one. They now go out on three separate days, each one paired with whatever off-platform writing lands the same afternoon, because those hours are the only time the platform itself is sending anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug in my own measurement
&lt;/h2&gt;

&lt;p&gt;Worth repeating because it nearly became a published claim.&lt;/p&gt;

&lt;p&gt;My first pass said my Actors were missing from the Jobs category page entirely, which would have been a much more dramatic post. The loop looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&amp;amp;category=JOBS&amp;amp;limit=100&amp;amp;offset=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;      &lt;span class="c1"&gt;# &amp;lt;-- wrong
&lt;/span&gt;    &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Store listing endpoint intermittently returns an empty page in the middle of a result set. Treating that as the end of the list stopped the scan at 994 records, my Actors were not in those 994, and the conclusion wrote itself. Retrying an empty page shows the category holds 6,066 Actors and mine are all in it, at positions 1,192 to 1,213. Buried by the same variable as everything else, not excluded.&lt;/p&gt;

&lt;p&gt;If you paginate this API, retry an empty page before you believe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself on day one
&lt;/h2&gt;

&lt;p&gt;Spend day one on distribution and day two on the Actor. Not the other way round, which is what I did.&lt;/p&gt;

&lt;p&gt;The build quality still matters, just not for the reason you think. It does not rank you. It is what makes the handful of people who arrive from somewhere else come back, and a returning user is the only input to the one number that does rank you.&lt;/p&gt;

&lt;p&gt;The Actors every number above is measured on are at &lt;a href="https://apify.com/dododata" rel="noopener noreferrer"&gt;apify.com/dododata&lt;/a&gt;. Public sources only, no personal data, robots.txt respected, pay per event. Ask me anything about the measurement and I will run it again with your search term.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
      <category>startup</category>
    </item>
    <item>
      <title>5,966 job posts: who actually publishes pay, and who only looks like they don't</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:57:21 +0000</pubDate>
      <link>https://dev.to/dododata/5966-job-posts-who-actually-publishes-pay-and-who-only-looks-like-they-dont-1a2c</link>
      <guid>https://dev.to/dododata/5966-job-posts-who-actually-publishes-pay-and-who-only-looks-like-they-dont-1a2c</guid>
      <description>&lt;p&gt;Every applicant-tracking system has a structured pay field. Greenhouse calls it &lt;code&gt;pay_input_ranges&lt;/code&gt;, Ashby has &lt;code&gt;compensation&lt;/code&gt;, Lever has &lt;code&gt;salaryRange&lt;/code&gt;. If you are building anything on job data, that field is where you look for salary, and it is where most pipelines stop.&lt;/p&gt;

&lt;p&gt;I pulled 24 public career sites, 5,966 open jobs, and counted. Then I read the descriptions of the boards that came back empty, and the number moved by 445 jobs.&lt;/p&gt;

&lt;p&gt;Two of the 24 boards had no open jobs that day, so every count below comes from the 22 that did. The per-board numbers are published as open data at the end of this post, so you can check any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structured field
&lt;/h2&gt;

&lt;p&gt;One request per board, no browser, no login. Greenhouse at &lt;code&gt;boards-api.greenhouse.io/v1/boards/&amp;lt;board&amp;gt;/jobs&lt;/code&gt;, Ashby at &lt;code&gt;api.ashbyhq.com/posting-api/job-board/&amp;lt;org&amp;gt;?includeCompensation=true&lt;/code&gt;, Lever at &lt;code&gt;api.lever.co/v0/postings/&amp;lt;org&amp;gt;?mode=json&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Boards&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;th&gt;With structured pay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1,293&lt;/td&gt;
&lt;td&gt;72.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3,435&lt;/td&gt;
&lt;td&gt;56.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1,238&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lever's 3.2% is the first thing worth noticing. It is not that Lever companies pay in secret. &lt;code&gt;salaryRange&lt;/code&gt; exists on the Lever posting API, and almost nobody fills it in. Three of the four boards I pulled had it empty on every single job.&lt;/p&gt;

&lt;p&gt;Per company, the spread inside one system is wider than the gap between systems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;th&gt;Structured pay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Duolingo&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coinbase&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;217&lt;/td&gt;
&lt;td&gt;98.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ramp&lt;/td&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;td&gt;95.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;611&lt;/td&gt;
&lt;td&gt;89.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;816&lt;/td&gt;
&lt;td&gt;82.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databricks&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;874&lt;/td&gt;
&lt;td&gt;54.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;665&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Palantir&lt;/td&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;313&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The zeroes are not all the same zero
&lt;/h2&gt;

&lt;p&gt;Four of those 0.0% boards state a pay range in plain English in the job description. They just never filled in the field.&lt;/p&gt;

&lt;p&gt;I ran a range extractor over the descriptions of every board that reported zero:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Structured&lt;/th&gt;
&lt;th&gt;Found in description text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;0 of 47&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Palantir&lt;/td&gt;
&lt;td&gt;0 of 313&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;0 of 128&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affirm&lt;/td&gt;
&lt;td&gt;0 of 199&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;121&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;0 of 665&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spotify&lt;/td&gt;
&lt;td&gt;0 of 71&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;0 of 32&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Discord publishes pay on 94% of its jobs. Any dataset built on the structured field alone reports 0%.&lt;/p&gt;

&lt;p&gt;That is the finding: &lt;strong&gt;an empty pay field is a data-entry fact, not a pay-transparency fact.&lt;/strong&gt; Stripe and Spotify genuinely do not publish ranges. Discord, Palantir, Notion and Affirm do, in a sentence near the bottom of the post that no API returns as a number.&lt;/p&gt;

&lt;p&gt;Across the whole sample, the structured field says 48.8% of jobs publish pay. Reading the text as well takes it to 56.3%, and the gain is concentrated entirely in the boards that looked silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extracting a range from prose, without inventing numbers
&lt;/h2&gt;

&lt;p&gt;The naive version of this is a regex for two dollar amounts with a dash between them, and it is wrong within about ten jobs. Here is what actually breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Equity tables look exactly like salary ranges.&lt;/strong&gt; Affirm puts this in most of its posts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Equity Grade - 2 (awarded in US Dollars)
Equity grade: 2   New hire equity: $16,000-$24,000   Annual Refresh: $5,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$16,000-$24,000&lt;/code&gt; is a real range next to a real dollar sign, and it is not the salary. A blocklist on the preceding text is what saves you: &lt;code&gt;equity&lt;/code&gt;, &lt;code&gt;refresh&lt;/code&gt;, &lt;code&gt;bonus&lt;/code&gt;, &lt;code&gt;sign-on&lt;/code&gt;, &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;stipend&lt;/code&gt;, &lt;code&gt;RSU&lt;/code&gt;, &lt;code&gt;grant&lt;/code&gt;, &lt;code&gt;option&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-target earnings are not base pay.&lt;/strong&gt; Sales roles quote OTE, which is base plus commission at 100% attainment. Rolling it into a &lt;code&gt;salary_min&lt;/code&gt; column silently inflates every sales row in the dataset. I let those go unmatched on purpose, which is most of what the extractor misses on Affirm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A number needs a cue word in front of it, not just a currency symbol.&lt;/strong&gt; Requiring one of &lt;code&gt;base pay&lt;/code&gt;, &lt;code&gt;pay range&lt;/code&gt;, &lt;code&gt;salary range&lt;/code&gt;, &lt;code&gt;compensation range&lt;/code&gt;, &lt;code&gt;hourly rate&lt;/code&gt; and so on within the preceding 90 characters removes nearly all of the false positives without costing real matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hourly and annual are the same shape.&lt;/strong&gt; &lt;code&gt;$22 - $30&lt;/code&gt; and &lt;code&gt;$220,000 - $300,000&lt;/code&gt; differ by three zeros. Decide the period explicitly from the surrounding words, and refuse to guess when there is neither an hourly nor an annual cue and the top of the range is under $10,000.&lt;/p&gt;

&lt;p&gt;The whole thing is about thirty lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MONEY&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;`(?:&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;|USD\s?|£|€)\s?(\d{1,3}(?:,\d{3})+|\d{2,3}(?:\.\d+)?\s?[kK]\b|\d{2,6}(?:\.\d{2})?)`&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;RANGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MONEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s*(?:-|–|—|to|and)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s*&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MONEY&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g&lt;/span&gt;&lt;span class="dl"&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;PAY_CUE&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;base pay|pay range|base salary|salary range|compensation range|annual salary|hourly rate|pay rate|salary of|compensation of|remuneration&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOT_PAY&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;equity|refresh|bonus|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;]?&lt;/span&gt;&lt;span class="sr"&gt;on|401|stipend|budget|revenue|funding|raise&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;ds&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|valuation|grant|rsu|option&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;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;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;flat&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="nx"&gt;RANGE&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;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;90&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="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PAY_CUE&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;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;NOT_PAY&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;before&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...amounts, currency from the symbol, period from hourly/annual cues&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not handle &lt;code&gt;35 000 €&lt;/code&gt;, the French spacing convention, and I left it that way rather than half-supporting it. A French board returns null, which is honest, instead of a number pulled out of a format the parser does not really read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the two sources apart
&lt;/h2&gt;

&lt;p&gt;If you merge text-extracted ranges into the same column as the structured field and say nothing, you have made your dataset less trustworthy, not more. One came from a form the employer filled in. The other came from a regex over prose, and it can be wrong.&lt;/p&gt;

&lt;p&gt;Carry a third column. Mine is &lt;code&gt;salary_source&lt;/code&gt;, one of &lt;code&gt;structured&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, or null, and a run over Affirm and Duolingo looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200 rows   structured: 81   description: 94   none: 25
  duolingo   structured: 81
  affirm     description: 94   none: 25
  e.g. Administrative Assistant IV        115,000 - 165,000 USD
  e.g. Affirm Bank Strategic Finance Mgr  185,000 - 245,000 USD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone doing serious analysis can filter to &lt;code&gt;structured&lt;/code&gt; only. Anyone building a job board gets a pay range on four times as many Affirm roles. Nobody gets a number without knowing where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-board numbers, as open data
&lt;/h2&gt;

&lt;p&gt;Every board's counts, free and machine-readable, so you do not have to take the tables on trust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.apify.com/v2/datasets/6EJmXWfWFxmjjCUtl/items?clean=true&amp;amp;format=json
https://api.apify.com/v2/datasets/6EJmXWfWFxmjjCUtl/items?clean=true&amp;amp;format=csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per board: &lt;code&gt;jobs_open&lt;/code&gt;, &lt;code&gt;jobs_with_structured_pay&lt;/code&gt;, &lt;code&gt;structured_pay_pct&lt;/code&gt;, and where I also read the&lt;br&gt;
descriptions, &lt;code&gt;jobs_with_pay_in_description_text&lt;/code&gt; with &lt;code&gt;description_text_checked&lt;/code&gt; saying so. The two boards with&lt;br&gt;
no open jobs are in there as zeroes rather than quietly dropped, and every row carries the endpoint it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;

&lt;p&gt;The extractor ships in the Dodo Data job scrapers on Apify Store, across Greenhouse, Lever, Ashby, Workday and the multi-system one. Same 30-field row shape including &lt;code&gt;salary_source&lt;/code&gt;, $1 per 1,000 jobs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;https://apify.com/dododata/career-site-jobs-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to re-run the survey yourself, it is one request per board and the endpoints are all listed above. The interesting number is not the average. It is which of your target employers are in the "publishes pay, never filled in the field" bucket, because that one is invisible until you read the text.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>jobs</category>
      <category>data</category>
    </item>
    <item>
      <title>Scraping Workday career sites without a browser, and the 2,000-job ceiling</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Mon, 21 Sep 2026 03:09:50 +0000</pubDate>
      <link>https://dev.to/dododata/scraping-workday-career-sites-without-a-browser-and-the-2000-job-ceiling-h2e</link>
      <guid>https://dev.to/dododata/scraping-workday-career-sites-without-a-browser-and-the-2000-job-ceiling-h2e</guid>
      <description>&lt;p&gt;Greenhouse, Lever, Ashby and half a dozen other applicant-tracking systems publish a job feed you can fetch in one request. Workday, the one running the career pages of most large enterprises, does not. No &lt;code&gt;/jobs.json&lt;/code&gt;, no RSS, and the page itself is an empty JavaScript shell.&lt;/p&gt;

&lt;p&gt;It still does not need a browser. Every Workday career site talks to its own backend over a plain POST, and that endpoint answers anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The listing call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST https://&amp;lt;tenant&amp;gt;.&amp;lt;pod&amp;gt;.myworkdayjobs.com/wday/cxs/&amp;lt;tenant&amp;gt;/&amp;lt;site&amp;gt;/jobs
Content-Type: application/json

{ "appliedFacets": {}, "limit": 20, "offset": 0, "searchText": "" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything you need is in the career-page URL. &lt;code&gt;https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite&lt;/code&gt; gives tenant &lt;code&gt;nvidia&lt;/code&gt;, pod &lt;code&gt;wd5&lt;/code&gt;, site &lt;code&gt;NVIDIAExternalCareerSite&lt;/code&gt;. Mind the &lt;code&gt;en-US&lt;/code&gt; some sites insert before the site name.&lt;/p&gt;

&lt;p&gt;The reply is small and clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jobPostings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Senior Firmware Engineer – CSP Engagements"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"externalPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/job/US-CA-Santa-Clara/Senior-Firmware-Engineer---CSP-Engagements_JR1999599"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"locationsText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US, CA, Santa Clara"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"postedOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Posted Today"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bulletFields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"JR1999599"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to know before you build on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;limit&lt;/code&gt; caps at 20.&lt;/strong&gt; Ask for 50 and you get a 400, not a truncated page. A full career site is &lt;code&gt;total / 20&lt;/code&gt; requests, minimum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;total&lt;/code&gt; is not the number of jobs.&lt;/strong&gt; It is 2,000 on NVIDIA and 1,483 on Salesforce, and NVIDIA does not have exactly two thousand openings. 2,000 is the ceiling the site will paginate to. Past it you get empty pages. If a site reports exactly 2,000, assume you are seeing the 2,000 most recent and say so downstream rather than claiming a complete catalogue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only the first page carries &lt;code&gt;total&lt;/code&gt;.&lt;/strong&gt; Later pages return it as 0. Carry it forward yourself, or your loop will stop after page two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The descriptions are in the HTML, as JSON-LD
&lt;/h2&gt;

&lt;p&gt;The listing gives you title, location, date and requisition id, but no description, no country, no employment type. Those sit on each job's public page, and you do not have to parse the DOM for them: every Workday job page carries a schema.org &lt;code&gt;JobPosting&lt;/code&gt; block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;ld&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[type="application/ld+json"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&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;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JobPosting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ld&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ld.description, ld.datePosted, ld.employmentType,&lt;/span&gt;
&lt;span class="c1"&gt;// ld.jobLocation.address.addressCountry, ld.identifier.value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is one request per job, which is the whole cost of the job. Filter on the listing first, where title, location and date are all available, and only open what matches. A "remote engineering roles posted this week" run on a 2,000-job site is about 100 listing requests plus a handful of job pages, not 2,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Posted Today" is not a date
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;postedOn&lt;/code&gt; is relative text: &lt;code&gt;Posted Today&lt;/code&gt;, &lt;code&gt;Posted Yesterday&lt;/code&gt;, &lt;code&gt;Posted 3 Days Ago&lt;/code&gt;, &lt;code&gt;Posted 30+ Days Ago&lt;/code&gt;. The first three convert cleanly. The last one does not. It is a lower bound, not a date, and turning it into "30 days ago" invents precision the site never gave you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;workdayPostedOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt; &lt;span class="kr"&gt;string&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;days&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;else&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yesterday&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;days&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;else&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(\+?)\s&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;/&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;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// "30+" falls through to null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;days&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;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;The JSON-LD on the job page has a real &lt;code&gt;datePosted&lt;/code&gt;, so a row that opens the page gets the exact date anyway. The relative text only matters when you are filtering on the listing, which is exactly when you want it to be honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  robots.txt
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;https://&amp;lt;tenant&amp;gt;.&amp;lt;pod&amp;gt;.myworkdayjobs.com/robots.txt&lt;/code&gt; explicitly allows the career site and disallows &lt;code&gt;/talentcommunity/&lt;/code&gt; and &lt;code&gt;/refreshFacet/&lt;/code&gt;. Read it per tenant rather than assuming. It is one request, and it is the difference between a public page and someone's candidate area.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real run looks like
&lt;/h2&gt;

&lt;p&gt;500 jobs from one career site, with descriptions: &lt;strong&gt;526 requests, 2 minutes 30 seconds, 4 timeouts that all succeeded on retry&lt;/strong&gt;. The timeouts are worth planning for, because Workday's backend is slower than a static feed, and a 30-second request timeout with retries is not excessive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;

&lt;p&gt;If you would rather not maintain the pagination, the JSON-LD merge and the relative dates, it is on Apify Store as &lt;strong&gt;Workday Jobs Scraper &amp;amp; API&lt;/strong&gt;. Paste career-site URLs, filter by title keywords, location or "posted within N days" before any job page is opened, $1 per 1,000 jobs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/workday-jobs-scraper" rel="noopener noreferrer"&gt;https://apify.com/dododata/workday-jobs-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same 26-field shape as the Greenhouse, Lever and Ashby ones, so a multi-employer pipeline does not need a branch per system. If something changes, it is fixed within 48 hours, and that promise is on the listing.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>jobs</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Every Shopify store publishes its whole catalogue at one URL</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Sat, 19 Sep 2026 09:14:35 +0000</pubDate>
      <link>https://dev.to/dododata/every-shopify-store-publishes-its-whole-catalogue-at-one-url-46i1</link>
      <guid>https://dev.to/dododata/every-shopify-store-publishes-its-whole-catalogue-at-one-url-46i1</guid>
      <description>&lt;p&gt;If you have ever written a scraper for a Shopify store, you probably parsed the HTML. You did not have to.&lt;/p&gt;

&lt;p&gt;Every Shopify storefront serves its own catalogue as JSON at &lt;code&gt;/products.json&lt;/code&gt;. It is not a private API and not a leak. It is the storefront's own public endpoint, the same data the shop renders for any visitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.allbirds.com/products.json?limit=250&amp;amp;page=1
https://www.allbirds.com/collections/mens/products.json?limit=250&amp;amp;page=1
https://www.allbirds.com/meta.json          store name, currency, country
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;250 products per page, &lt;code&gt;page=N&lt;/code&gt; until you get an empty list. Collections work the same way, so you can scope a run to one part of a shop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a product actually contains
&lt;/h2&gt;

&lt;p&gt;The interesting part is not the title, it is &lt;code&gt;variants&lt;/code&gt;. Each variant is a real SKU with its own price, its own crossed-out price and its own stock flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7205191974992&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Daily Grind Women's Corduroy Workwear Jacket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Death Wish Coffee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DWC-JKT-S"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"65.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"compare_at_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"90.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"option1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"S"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DWC-JKT-M"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"65.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"compare_at_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"90.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"option1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"M"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things follow from that shape, and both are easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A discount only means something inside one variant.&lt;/strong&gt; If you take the lowest price in the product and compare it with the highest &lt;code&gt;compare_at_price&lt;/code&gt; in the product, a shop that sells a $10 accessory and a $100 jacket marked down from $120 will report a 92% discount. It is not on sale by 92%; those are two different things being subtracted from each other. Compute the discount per variant, then take the best one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;discounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;variants&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="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;on_sale&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compare_at_price&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;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="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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compare_at_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bestDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;discounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="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;discounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stock is per variant too.&lt;/strong&gt; "In stock" for a product usually means &lt;em&gt;some size&lt;/em&gt; is in stock. If you are monitoring availability, one row per variant is the only honest shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prices are strings, and the currency is somewhere else
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;price&lt;/code&gt; arrives as &lt;code&gt;"65.00"&lt;/code&gt;, a string. &lt;code&gt;/products.json&lt;/code&gt; never tells you the currency, which lives in &lt;code&gt;/meta.json&lt;/code&gt; alongside the shop's name and country. Fetch it once per store and attach it to every row, or your dataset silently mixes USD, EUR and GBP in one column.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real run
&lt;/h2&gt;

&lt;p&gt;Two stores (Allbirds and Death Wish Coffee), one run on 2026-09-17: &lt;strong&gt;439 products, 3,282 variants, 9 seconds, 5 requests&lt;/strong&gt;. 23 products were on sale, 246 had at least one variant in stock, and every row carried the store's currency. The deepest genuine discount was a pair of socks at $4 against a $16 compare-at price, computed inside one variant, which is why it can be trusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the endpoint will not give you
&lt;/h2&gt;

&lt;p&gt;Some shops turn &lt;code&gt;/products.json&lt;/code&gt; off or put it behind a bot check; Gymshark, for one, returns 403. Treat that as a normal outcome, log it and move on. It is a per-store decision by the merchant, not a puzzle to defeat. And a site that is not on Shopify simply 404s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;

&lt;p&gt;If you would rather not maintain the pagination, the variant maths and the per-store currency, it is on Apify Store as &lt;strong&gt;Shopify Products Scraper &amp;amp; API&lt;/strong&gt;. Paste store or collection URLs, choose one row per product or per variant, filter to in-stock or on-sale only, $1 per 1,000 rows:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/shopify-products-scraper" rel="noopener noreferrer"&gt;https://apify.com/dododata/shopify-products-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; field is stable across runs (&lt;code&gt;shopify:store:product:variant&lt;/code&gt;), so a daily run plus a diff is a price and stock monitor in about one line of code. If something changes, it is fixed within 48 hours, and that promise is on the listing.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>ecommerce</category>
      <category>api</category>
      <category>json</category>
    </item>
    <item>
      <title>Every open job at 500 companies, as JSON, without scraping a single job board</title>
      <dc:creator>Dodo Data</dc:creator>
      <pubDate>Fri, 18 Sep 2026 07:50:29 +0000</pubDate>
      <link>https://dev.to/dododata/every-open-job-at-500-companies-as-json-without-scraping-a-single-job-board-2431</link>
      <guid>https://dev.to/dododata/every-open-job-at-500-companies-as-json-without-scraping-a-single-job-board-2431</guid>
      <description>&lt;p&gt;Most "job data" projects start by scraping LinkedIn or Indeed, and most of them die there: captchas, logins, lawsuits, and listings that are already three reposts away from the employer.&lt;/p&gt;

&lt;p&gt;There is a quieter source. Most tech companies run their career page on an applicant-tracking system: Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee, Workable, Personio, Breezy, Pinpoint, Rippling, Teamtailor. Every one of those systems publishes a job feed so the career page can be embedded on the company's site. First-party, current, no login, no anti-bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feeds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Greenhouse       https://boards-api.greenhouse.io/v1/boards/{company}/jobs?content=true&amp;amp;pay_transparency=true
Lever            https://api.lever.co/v0/postings/{company}?mode=json
Ashby            https://api.ashbyhq.com/posting-api/job-board/{company}?includeCompensation=true
SmartRecruiters  https://api.smartrecruiters.com/v1/companies/{company}/postings
Recruitee        https://{company}.recruitee.com/api/offers/
Workable         https://apply.workable.com/api/v1/widget/accounts/{company}?details=true
Personio         https://{company}.jobs.personio.de/xml
Breezy HR        https://{company}.breezy.hr/json
Pinpoint         https://{company}.pinpointhq.com/postings.json
Rippling         https://api.rippling.com/platform/api/ats/v1/board/{company}/jobs
Teamtailor       https://{career-site-host}/jobs.rss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{company}&lt;/code&gt; is the slug in the career-page URL: &lt;code&gt;jobs.lever.co/spotify&lt;/code&gt; gives &lt;code&gt;spotify&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: eleven shapes
&lt;/h2&gt;

&lt;p&gt;The feeds agree on nothing. Greenhouse sends the description as HTML that has been entity-escaped once (&lt;code&gt;&amp;amp;lt;p&amp;amp;gt;&lt;/code&gt;), pay ranges in cents, and the location as &lt;code&gt;{ "name": ... }&lt;/code&gt;. Lever sends &lt;code&gt;createdAt&lt;/code&gt; as a millisecond string and splits the description across &lt;code&gt;descriptionPlain&lt;/code&gt;, &lt;code&gt;lists[]&lt;/code&gt; and &lt;code&gt;additionalPlain&lt;/code&gt;. Ashby nests compensation three levels deep. SmartRecruiters paginates and needs one more request per posting for the text. Personio is XML, Teamtailor is RSS with its own namespace, and Rippling's feed has no descriptions at all.&lt;/p&gt;

&lt;p&gt;Un-escaping Greenhouse, for instance, is a two-step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;htmlToText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;html&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;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&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;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;x&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/x&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// un-escape once&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&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;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*br&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;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&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;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;p|div|li|h&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;1-6&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|ul|ol|tr&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&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;/&amp;lt;li&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;x&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/x&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Workday, the twelfth system, has no feed. Its career sites are allowed by robots.txt and every job page carries schema.org &lt;code&gt;JobPosting&lt;/code&gt; as JSON-LD, so it is read the old-fashioned way: listing, then page.)&lt;/p&gt;

&lt;h2&gt;
  
  
  One shape out
&lt;/h2&gt;

&lt;p&gt;I normalised all twelve into 26 fields: &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;department&lt;/code&gt;, &lt;code&gt;team&lt;/code&gt;, &lt;code&gt;locations[]&lt;/code&gt;, &lt;code&gt;workplace_type&lt;/code&gt; (remote / hybrid / onsite), &lt;code&gt;salary_min&lt;/code&gt;, &lt;code&gt;salary_max&lt;/code&gt;, &lt;code&gt;salary_currency&lt;/code&gt;, &lt;code&gt;posted_at&lt;/code&gt;, &lt;code&gt;apply_url&lt;/code&gt;, plain-text &lt;code&gt;description&lt;/code&gt;, and a stable &lt;code&gt;id&lt;/code&gt; (&lt;code&gt;ats:company:job&lt;/code&gt;) so a daily run can be diffed in one line.&lt;/p&gt;

&lt;p&gt;A real run on 2026-09-17, three boards (Duolingo on Greenhouse, Spotify on Lever, Ramp on Ashby): &lt;strong&gt;304 jobs in 18 seconds, 224 of them (74%) with a published pay range&lt;/strong&gt; already parsed to numbers.&lt;/p&gt;

&lt;p&gt;Two things I deliberately leave out: recruiter names and mailboxes that some feeds carry, and any e-mail or phone number inside a description (replaced with &lt;code&gt;[email removed]&lt;/code&gt;). It is job data, not people data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;

&lt;p&gt;If you would rather not maintain twelve parsers, it is on Apify Store as &lt;strong&gt;Career Site Jobs Scraper&lt;/strong&gt;. Paste career-page URLs, filter by title keywords, location or "posted within N days", $1 per 1,000 jobs, callable from the API or from an AI agent over MCP:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apify.com/dododata/career-site-jobs-scraper" rel="noopener noreferrer"&gt;https://apify.com/dododata/career-site-jobs-scraper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a feed changes shape, it is fixed within 48 hours, and that promise is on the listing.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>jobs</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
