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