<?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: Donglin Li</title>
    <description>The latest articles on DEV Community by Donglin Li (@realjons).</description>
    <link>https://dev.to/realjons</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%2F4239415%2Fcee21429-f54c-400a-b9fe-9db69870b292.jpg</url>
      <title>DEV Community: Donglin Li</title>
      <link>https://dev.to/realjons</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/realjons"/>
    <language>en</language>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Thu, 12 Nov 2026 04:23:01 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-2cp1</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-2cp1</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Thu, 12 Nov 2026 03:23:02 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1o54</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1o54</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Thu, 12 Nov 2026 02:50:01 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1pe2</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1pe2</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Thu, 12 Nov 2026 00:50:01 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-c2o</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-c2o</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Wed, 11 Nov 2026 21:23:01 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-527j</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-527j</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Wed, 11 Nov 2026 20:50:02 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1705</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-1705</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Wed, 11 Nov 2026 19:50:02 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-4of3</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-4of3</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Wed, 11 Nov 2026 17:50:02 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-p6m</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-p6m</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
    <item>
      <title>I aggregated 3 ATS job boards (Greenhouse, Lever, Ashby) into one normalized dataset — here's what I learned</title>
      <dc:creator>Donglin Li</dc:creator>
      <pubDate>Wed, 11 Nov 2026 16:14:53 +0000</pubDate>
      <link>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-3f4e</link>
      <guid>https://dev.to/realjons/i-aggregated-3-ats-job-boards-greenhouse-lever-ashby-into-one-normalized-dataset-heres-what-3f4e</guid>
      <description>&lt;p&gt;Job postings data is messy. Every company uses a different ATS, and each ATS returns a completely different schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt;: integer IDs, &lt;code&gt;location.name&lt;/code&gt; nested object, &lt;code&gt;departments&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt;: UUIDs, flat &lt;code&gt;categories&lt;/code&gt; object, workplace type as separate field&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt;: different ID format again, &lt;code&gt;isRemote&lt;/code&gt; boolean, 970KB responses that stream-truncate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built an Apify Actor to normalize all three into one schema. Technical notes:&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-encoded HTML trap
&lt;/h2&gt;

&lt;p&gt;Greenhouse serves some boards with double-encoded HTML: &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;Salary: $121,600...&amp;amp;lt;/p&amp;amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;p&amp;gt;...&amp;lt;/p&amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape()&lt;/code&gt; pass isn't enough — you need two, and you need to unescape &lt;em&gt;before&lt;/em&gt; truncating, or a 400-char snippet becomes pure &lt;code&gt;&amp;amp;lt;&lt;/code&gt; entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Salary extraction is a regex problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;The biggest competitor in this space uses AI enrichment for salary fields — and has &lt;strong&gt;0% coverage&lt;/strong&gt; on the boards I tested. Plain regex with context windows ("salary range", "Salary:", "base pay", "Base pay: $85/hr") gets &lt;strong&gt;37.8%&lt;/strong&gt; overall and 77% on companies that publish ranges. Sometimes boring beats clever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting changes your architecture
&lt;/h2&gt;

&lt;p&gt;My home IP got SSL-reset by one ATS after heavy polling. The fix: automatic proxy fallback (direct first, then proxy on connection errors). Without this, your nightly cron silently dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental monitoring beats full downloads
&lt;/h2&gt;

&lt;p&gt;Buyers don't want a 50k-row dataset re-downloaded daily. They want "alert me when tracked companies post new jobs". &lt;code&gt;sinceDays&lt;/code&gt; + fingerprint dedup (company+title+location hash) turns a data dump into a monitoring service.&lt;/p&gt;




&lt;p&gt;Live on the Apify Store: &lt;a href="https://apify.com/daj8/ats-jobs-aggregator" rel="noopener noreferrer"&gt;ATS Jobs Aggregator&lt;/a&gt; — normalized postings across 3 ATS platforms, salary extraction included.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>jobs</category>
      <category>python</category>
    </item>
  </channel>
</rss>
