<?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: Daniel Meshulam</title>
    <description>The latest articles on DEV Community by Daniel Meshulam (@glitchbound).</description>
    <link>https://dev.to/glitchbound</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%2F4055417%2F57611ce1-a653-43cc-a2cc-478624eb75e9.png</url>
      <title>DEV Community: Daniel Meshulam</title>
      <link>https://dev.to/glitchbound</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/glitchbound"/>
    <language>en</language>
    <item>
      <title>Merging job postings from ten ATS platforms into one schema, and the four fields that fight back</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:56:22 +0000</pubDate>
      <link>https://dev.to/glitchbound/merging-job-postings-from-ten-ats-platforms-into-one-schema-and-the-four-fields-that-fight-back-1g72</link>
      <guid>https://dev.to/glitchbound/merging-job-postings-from-ten-ats-platforms-into-one-schema-and-the-four-fields-that-fight-back-1g72</guid>
      <description>&lt;p&gt;Reading one ATS job board API is easy. Every one of them is public JSON with&lt;br&gt;
no key. Reading ten and getting rows you can actually query together is a&lt;br&gt;
different job, and it is almost entirely about four fields.&lt;/p&gt;

&lt;p&gt;I run an index across Greenhouse, Ashby, Workable, Workday, BambooHR, Breezy,&lt;br&gt;
Teamtailor, Personio, Recruitee and Rippling. Here is what actually broke,&lt;br&gt;
with the measurements.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Remote is not a boolean, and one API will lie to you about it
&lt;/h2&gt;

&lt;p&gt;Ashby postings carry &lt;code&gt;isRemote&lt;/code&gt;. It looks like exactly the field you want.&lt;/p&gt;

&lt;p&gt;Measured on Ramp's board, 1 August 2026: &lt;code&gt;isRemote&lt;/code&gt; was &lt;strong&gt;true for 117 of 126&lt;br&gt;
postings&lt;/strong&gt;, while the same postings' &lt;code&gt;workplaceType&lt;/code&gt; said Hybrid 101, Remote&lt;br&gt;
16, OnSite 9.&lt;/p&gt;

&lt;p&gt;Ashby's &lt;code&gt;isRemote&lt;/code&gt; means "not fully on-site". Trust it and a user asking for&lt;br&gt;
remote work gets a hundred hybrid roles at a New York office.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workplaceType&lt;/code&gt; is the honest field, and it has three values, because &lt;strong&gt;hybrid&lt;br&gt;
is a real third state that a boolean cannot hold.&lt;/strong&gt; Normalise to the three-way&lt;br&gt;
enum and derive the boolean from it, never the reverse:&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;workplace_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&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;workplaceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# Remote | Hybrid | OnSite
&lt;/span&gt;&lt;span class="n"&gt;is_remote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workplace_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Remote&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="n"&gt;workplace_type&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;None&lt;/code&gt;. A platform that publishes no arrangement at all gets unknown,&lt;br&gt;
not &lt;code&gt;False&lt;/code&gt;. "We do not know" and "we know it is not remote" are different&lt;br&gt;
answers and collapsing them is how a filter silently drops good rows.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Dates arrive in three shapes, one of which is relative
&lt;/h2&gt;

&lt;p&gt;ISO 8601 from some. Epoch milliseconds from others. And Workday hands you a&lt;br&gt;
&lt;strong&gt;human sentence&lt;/strong&gt;: &lt;code&gt;"Posted 4 Days Ago"&lt;/code&gt;, &lt;code&gt;"Posted Today"&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;"Posted 30+ Days Ago"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Epoch handling has a cheap tell:&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="c1"&gt;# Milliseconds since the epoch are 13 digits until the year 2286.
&lt;/span&gt;&lt;span class="n"&gt;secs&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;1000&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10_000_000_000&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relative ones need parsing, and one of them must be refused:&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;_AGO&lt;/span&gt; &lt;span class="o"&gt;=&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;compile&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;^posted\s+(\d+)\s+days?\s+ago$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;workday_posted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;strip&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posted today&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&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="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posted yesterday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&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="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_AGO&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="n"&gt;t&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;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# "posted 30+ days ago" lands here, on purpose
&lt;/span&gt;        &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&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="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"Posted 30+ Days Ago"&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; deliberately. It could be 31 days or&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turning it into "31 days" produces a date that sorts, filters and charts
perfectly and is fiction. Keep the original string in a separate field and let
the caller see what the source actually said.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Employment type can vary between two boards on the SAME platform
&lt;/h2&gt;

&lt;p&gt;This one surprised me. It is not just that platforms disagree with each other.&lt;br&gt;
Workday's &lt;code&gt;timeType&lt;/code&gt; is &lt;strong&gt;tenant-configured&lt;/strong&gt;, so two customers of the same ATS&lt;br&gt;
publish different things.&lt;/p&gt;

&lt;p&gt;Measured 2026-08-08 across ten Workday boards: &lt;strong&gt;two publish &lt;code&gt;timeType&lt;/code&gt; on&lt;br&gt;
every posting, and eight publish it on none.&lt;/strong&gt; The only value that appeared at&lt;br&gt;
all was &lt;code&gt;"Full time"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So "does this platform have employment type" is not a question with an answer,&lt;br&gt;
and any per-platform lookup table encodes a guess. Read the field where the&lt;br&gt;
board provides it, map it through one shared vocabulary, and leave it null&lt;br&gt;
everywhere else. I had this branch hard-coded to &lt;code&gt;None&lt;/code&gt; for a while, which&lt;br&gt;
meant 258,876 rows carried no employment type even where Workday had plainly&lt;br&gt;
stated one.&lt;/p&gt;

&lt;p&gt;The trap on the other side is a filter for "full-time" that silently excludes&lt;br&gt;
the majority of your index, because most rows are null rather than false.&lt;br&gt;
Filter on &lt;code&gt;!= 'part-time'&lt;/code&gt; if you mean "not part-time", and say in your docs&lt;br&gt;
which one you did.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Company name is often not a company name
&lt;/h2&gt;

&lt;p&gt;Several platforms return the board token where you expect the employer, so you&lt;br&gt;
get &lt;code&gt;acmecorp&lt;/code&gt; instead of &lt;code&gt;Acme Corp&lt;/code&gt;, and on Workday you can get the hostname.&lt;br&gt;
Pull the display name from the board envelope rather than the posting, and if&lt;br&gt;
it is absent, say so instead of shipping a slug that looks like a name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule underneath all four
&lt;/h2&gt;

&lt;p&gt;Every one of these bugs has the same shape: &lt;strong&gt;a field existed, so it got&lt;br&gt;
trusted.&lt;/strong&gt; &lt;code&gt;isRemote&lt;/code&gt; exists. A parsed "30+ days" exists. A board token&lt;br&gt;
exists where a company name goes. Each one produces a value that is&lt;br&gt;
well-formed, sorts correctly, and is wrong.&lt;/p&gt;

&lt;p&gt;The fix is always the same and it is not technical. Carry unknown as unknown.&lt;br&gt;
Three-state where reality has three states. Null where the source said nothing.&lt;br&gt;
It makes your schema uglier and your answers true, and in this data the null&lt;br&gt;
rate is itself information: it tells you which platform to stop promising&lt;br&gt;
things about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index, if you want the rows rather than the code
&lt;/h2&gt;

&lt;p&gt;24,280 company boards, 10 ATS platforms, 688,711 open roles in one schema,&lt;br&gt;
rebuilt nightly, no login and no company list. On Apify as&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ats-jobs-search" rel="noopener noreferrer"&gt;ATS Jobs Search API&lt;/a&gt;, or&lt;br&gt;
single-platform:&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/greenhouse-jobs-api" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/workday-jobs-api" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ashby-jobs-api" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/workable-jobs-api" rel="noopener noreferrer"&gt;Workable&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/bamboohr-jobs-api" rel="noopener noreferrer"&gt;BambooHR&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to see the merged fields on real rows before deciding whether the schema is worth it, every vertical page at &lt;a href="https://glitchbound-jobs.pages.dev/" rel="noopener noreferrer"&gt;glitchbound-jobs.pages.dev&lt;/a&gt; is built straight from it: &lt;a href="https://glitchbound-jobs.pages.dev/jobs/game-dev/" rel="noopener noreferrer"&gt;game dev&lt;/a&gt;, &lt;a href="https://glitchbound-jobs.pages.dev/jobs/nursing/" rel="noopener noreferrer"&gt;nursing&lt;/a&gt;, &lt;a href="https://glitchbound-jobs.pages.dev/jobs/devops/" rel="noopener noreferrer"&gt;devops&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Posted date is populated on 76.2% of rows and country on 78.0%. Those are the&lt;br&gt;
real numbers, not the ones I would like, and the missing quarter is mostly&lt;br&gt;
Workday's "30+ days" refusing to become a date.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>How to know which job postings were removed, when nothing tells you</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:53:08 +0000</pubDate>
      <link>https://dev.to/glitchbound/how-to-know-which-job-postings-were-removed-when-nothing-tells-you-4aoc</link>
      <guid>https://dev.to/glitchbound/how-to-know-which-job-postings-were-removed-when-nothing-tells-you-4aoc</guid>
      <description>&lt;p&gt;Every ATS job board API answers the same question: what is open right now.&lt;br&gt;
Greenhouse, Ashby, Workable, Workday, all of them hand you the current list.&lt;/p&gt;

&lt;p&gt;None of them tell you what &lt;strong&gt;disappeared&lt;/strong&gt;. There is no &lt;code&gt;closed_at&lt;/code&gt;, no&lt;br&gt;
deletions feed, no tombstone. A role that was filled yesterday is simply not&lt;br&gt;
in today's response, indistinguishable from a role that never existed.&lt;/p&gt;

&lt;p&gt;And disappearance is usually the more interesting half. A company that opened&lt;br&gt;
40 roles and closed 38 is not the same company as one that opened 40 and&lt;br&gt;
closed none, and the open-roles endpoint reports both as 40.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mechanic is a diff, and it is not hard
&lt;/h2&gt;

&lt;p&gt;You cannot ask for closures. You have to have been watching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boardToken&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobId&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;firstSeen&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- first run this job appeared in&lt;/span&gt;
    &lt;span class="n"&gt;lastSeen&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- most recent run it appeared in&lt;/span&gt;
    &lt;span class="n"&gt;closedOn&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- set when it stops appearing&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boardToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobId&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;Each run: upsert everything you fetched with today's date in &lt;code&gt;lastSeen&lt;/code&gt;, then&lt;br&gt;
mark the rest closed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;closedOn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;closedOn&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;lastSeen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;boardToken&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;boardToken&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fetched_ok_today&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one people leave out, and it is the difference between&lt;br&gt;
a closure feed and a random number generator.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three ways this goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A failed fetch is not a closure.&lt;/strong&gt; If a board 500s, times out, or you get&lt;br&gt;
rate limited, every job on it vanishes from your response. Diff naively and&lt;br&gt;
you have just recorded 2,000 closures at one company on the day their CDN had&lt;br&gt;
a bad afternoon. &lt;strong&gt;Only diff boards that answered successfully this run.&lt;/strong&gt;&lt;br&gt;
Everything else keeps yesterday's state untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A paginated board that stopped early is the same bug wearing a hat.&lt;/strong&gt; If you&lt;br&gt;
read 3 of 7 pages and treat the result as the complete current state, pages&lt;br&gt;
4 to 7 all "closed". Track per-board fetch completeness, not just HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot know how long a job was open if you started watching after it&lt;br&gt;
was posted.&lt;/strong&gt; This is the subtle one. A job you first saw on the day you&lt;br&gt;
started watching may have been open for a week or a year. Computing&lt;br&gt;
&lt;code&gt;daysOpen = closedOn - firstSeen&lt;/code&gt; on that row invents a number.&lt;/p&gt;

&lt;p&gt;The fix is to record it as unknown rather than compute it anyway:&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;board_first_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min_first_seen_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;board_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;knowable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;board_first_seen&lt;/span&gt;   &lt;span class="c1"&gt;# strictly after, not equal
&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closed_on&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_seen&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;knowable&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Open for 3 days" and "we do not know how long it was open" must never look&lt;br&gt;
the same, because one of them is a signal about the company and the other is&lt;br&gt;
an artefact of when you happened to start.&lt;/p&gt;

&lt;p&gt;The same discipline applies upward: a company's &lt;code&gt;closed30&lt;/code&gt; should be &lt;strong&gt;absent&lt;/strong&gt;&lt;br&gt;
until you have watched it for 30 days, not &lt;code&gt;0&lt;/code&gt;. "Closed nothing" and "we have&lt;br&gt;
not been looking long enough" send a reader to two different conclusions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it gets you once it runs
&lt;/h2&gt;

&lt;p&gt;Closures turn a job board into a time series. Net change per company. Roles&lt;br&gt;
that close in four days, which usually means an internal candidate. Roles that&lt;br&gt;
sit open for six months, which usually means the requisition is a placeholder.&lt;br&gt;
A company whose closures suddenly outrun its openings, which is a churn signal&lt;br&gt;
worth a phone call if they are your customer.&lt;/p&gt;

&lt;p&gt;None of that is visible in the endpoint everyone reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not run it for a month first
&lt;/h2&gt;

&lt;p&gt;That is the real cost here: this data does not exist until you have collected&lt;br&gt;
it, and the first month produces nothing.&lt;/p&gt;

&lt;p&gt;I have been running the diff across 24,280 company boards on 10 ATS platforms,&lt;br&gt;
and the index currently holds 688,711 open roles and &lt;strong&gt;29,027 recorded&lt;br&gt;
closures&lt;/strong&gt;, rebuilt nightly. It is on Apify as&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ats-jobs-search" rel="noopener noreferrer"&gt;ATS Jobs Search API&lt;/a&gt;, with&lt;br&gt;
company-level opening and closing counts in&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/b2b-leads-finder" rel="noopener noreferrer"&gt;B2B Leads Finder&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The closure counts are readable without an account or a key at &lt;a href="https://glitchbound-jobs.pages.dev/" rel="noopener noreferrer"&gt;glitchbound-jobs.pages.dev&lt;/a&gt;, which has a &lt;a href="https://glitchbound-jobs.pages.dev/closed-jobs/" rel="noopener noreferrer"&gt;page on closed roles&lt;/a&gt; rebuilt from the same nightly diff.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;daysOpen&lt;/code&gt; is null wherever it is not knowable, for the reason above. If you&lt;br&gt;
find a row where that rule looks wrong, tell me, because that is the exact&lt;br&gt;
mistake this is trying not to make.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>database</category>
    </item>
    <item>
      <title>How to find any company's job board from just its domain, without a list</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:48:49 +0000</pubDate>
      <link>https://dev.to/glitchbound/how-to-find-any-companys-job-board-from-just-its-domain-without-a-list-5aja</link>
      <guid>https://dev.to/glitchbound/how-to-find-any-companys-job-board-from-just-its-domain-without-a-list-5aja</guid>
      <description>&lt;p&gt;Most tech companies run their careers page on an ATS: Greenhouse, Ashby,&lt;br&gt;
Workable, Workday, BambooHR and a handful of others. Nearly all of those&lt;br&gt;
expose a public JSON endpoint for their postings, no key and no login.&lt;/p&gt;

&lt;p&gt;The endpoint needs a &lt;strong&gt;board token&lt;/strong&gt;, though, which is the company's slug&lt;br&gt;
inside that ATS. And that is the actual problem. You have &lt;code&gt;stripe.com&lt;/code&gt;. You&lt;br&gt;
need to know that Stripe is on Greenhouse under the token &lt;code&gt;stripe&lt;/code&gt;, and every&lt;br&gt;
tutorial starts one step after that, with the token already in hand.&lt;/p&gt;

&lt;p&gt;Here is what actually works, and the measurement that says so.&lt;/p&gt;
&lt;h2&gt;
  
  
  The obvious approach is the worse one
&lt;/h2&gt;

&lt;p&gt;Fetch the company's careers page, look for a link to &lt;code&gt;boards.greenhouse.io&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;jobs.ashbyhq.com&lt;/code&gt;, pull the token out of the URL.&lt;/p&gt;

&lt;p&gt;Measured across 30 companies: &lt;strong&gt;that finds the board 50% of the time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It fails on exactly the companies you most want. Stripe's careers page is a&lt;br&gt;
JavaScript application that names no board anywhere in the delivered HTML.&lt;br&gt;
Render it in a headless browser and you have turned a 200ms JSON fetch into a&lt;br&gt;
multi-second browser run, for a token that is five characters long.&lt;/p&gt;
&lt;h2&gt;
  
  
  Guess the token, then verify it
&lt;/h2&gt;

&lt;p&gt;The better approach inverts it. Derive candidate tokens from the domain, then&lt;br&gt;
ask each ATS whether that board exists. On these platforms a bogus token&lt;br&gt;
returns 404 and a real one returns 200 with the postings, so &lt;strong&gt;a 200 is&lt;br&gt;
proof.&lt;/strong&gt; You never need the careers page at all.&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;httpx&lt;/span&gt;

&lt;span class="n"&gt;BOARDS&lt;/span&gt; &lt;span class="o"&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;greenhouse&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;https://boards-api.greenhouse.io/v1/boards/{t}/jobs&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;ashby&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;https://api.ashbyhq.com/posting-api/job-board/{t}&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;workable&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;https://apply.workable.com/api/v1/widget/accounts/{t}?details=true&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;bamboohr&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;https://{t}.bamboohr.com/careers/list&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;//&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="mi"&gt;1&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&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;ats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BOARDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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="n"&gt;httpx&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# a 200 here is proof
&lt;/span&gt;            &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&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="nf"&gt;json&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;hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured on the same 30 companies: &lt;strong&gt;90%&lt;/strong&gt;, including Stripe, whose Greenhouse&lt;br&gt;
board answers with more than 500 open roles the careers page never mentioned.&lt;br&gt;
(Check it yourself: &lt;code&gt;boards-api.greenhouse.io/v1/boards/stripe/jobs&lt;/code&gt;. The&lt;br&gt;
count moves daily, which is rather the point.)&lt;/p&gt;

&lt;p&gt;One attempt per guess, not a retry ladder. A guessed token either exists or it&lt;br&gt;
does not, and retrying every wrong guess three times is what turns a sweep of&lt;br&gt;
90 domains into 25 seconds each.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that will bite you
&lt;/h2&gt;

&lt;p&gt;Two failure modes look identical to a naive script, and both produce a&lt;br&gt;
confident wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A company can run more than one ATS.&lt;/strong&gt; Take the board with the most&lt;br&gt;
postings, not the first one that answers 200. Otherwise a company with a stale&lt;br&gt;
Lever board from 2023 and a live Greenhouse board resolves to the dead one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty board is a real answer, and it is not the answer you want.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;nvidia.com&lt;/code&gt; resolves to a genuine Workable account with &lt;strong&gt;zero&lt;/strong&gt; open roles.&lt;br&gt;
NVIDIA has about 2,000 postings. They are on Workday.&lt;/p&gt;

&lt;p&gt;So "Workable, 0 roles" is true and completely misleading. If you return that&lt;br&gt;
bare, your caller reads "NVIDIA is not hiring", which is the same lie a 404&lt;br&gt;
tells when you read it as "nothing found". Return the emptiness as a fact of&lt;br&gt;
its own:&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;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&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;h&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;h&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&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;ats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;best&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;best&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isEmpty&lt;/span&gt;&lt;span class="sh"&gt;"&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;best&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alternatives&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;h&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;best&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;And Workday cannot be guessed at all.&lt;/strong&gt; Its URLs look like&lt;br&gt;
&lt;code&gt;https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite&lt;/code&gt;: a tenant, a&lt;br&gt;
numbered data centre and a site name, none of which follow from the domain.&lt;br&gt;
For Workday you need the careers URL itself. There is no trick here, and any&lt;br&gt;
library that claims to find Workday from a domain is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not run it yourself
&lt;/h2&gt;

&lt;p&gt;I maintain an index built this way: 24,280 company boards across 10 ATS&lt;br&gt;
platforms, 688,711 open roles, rebuilt nightly, no login and no company list&lt;br&gt;
needed. It is on Apify as&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ats-jobs-search" rel="noopener noreferrer"&gt;ATS Jobs Search API&lt;/a&gt;, and the&lt;br&gt;
single-platform ones are&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/greenhouse-jobs-api" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/workday-jobs-api" rel="noopener noreferrer"&gt;Workday&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ashby-jobs-api" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/workable-jobs-api" rel="noopener noreferrer"&gt;Workable&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/bamboohr-jobs-api" rel="noopener noreferrer"&gt;BambooHR&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you would rather look at what is in there before wiring anything up, the index is browsable at &lt;a href="https://glitchbound-jobs.pages.dev/" rel="noopener noreferrer"&gt;glitchbound-jobs.pages.dev&lt;/a&gt;, including a &lt;a href="https://glitchbound-jobs.pages.dev/find-a-job-board/" rel="noopener noreferrer"&gt;page for this exact problem&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The 30-company and 90-domain numbers above are from building it. If you hit a&lt;br&gt;
case where guess-and-verify fails and the careers page would have worked, I&lt;br&gt;
would genuinely like to know which company.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Find companies that are hiring, and how to reach them, without touching LinkedIn</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:34:20 +0000</pubDate>
      <link>https://dev.to/glitchbound/find-companies-that-are-hiring-and-how-to-reach-them-without-touching-linkedin-k24</link>
      <guid>https://dev.to/glitchbound/find-companies-that-are-hiring-and-how-to-reach-them-without-touching-linkedin-k24</guid>
      <description>&lt;p&gt;Every B2B prospecting tool sells you the same thing: a list of companies, and maybe an email pattern. The problem was never the list. It is knowing &lt;strong&gt;which companies to call this week&lt;/strong&gt;, and almost nothing in a static database tells you that.&lt;/p&gt;

&lt;p&gt;Hiring does. A company that opened 40 roles this month has budget and is changing shape. A company opening its &lt;strong&gt;first&lt;/strong&gt; sales, security or finance role has just changed strategy, and that is the single strongest timing signal you can get for free.&lt;/p&gt;

&lt;p&gt;And it is genuinely free, because companies publish their openings through APIs designed to be read. Greenhouse, Ashby, Workable, Recruitee, Personio, Rippling and BambooHR each expose a public JSON or XML endpoint per company: no login, no cookies, no proxy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://boards-api.greenhouse.io/v1/boards/stripe/jobs"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole trick, and it is why job aggregators exist at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;The per-company endpoint needs the company's board token. There is no directory of tokens. So "who is hiring for sales in Berlin" is unanswerable from these APIs directly: you would need the token of every company that might be, which is the list you were trying to build.&lt;/p&gt;

&lt;p&gt;You have to invert it. Enumerate the boards first, then read all of them on a schedule. I run that index: 11,157 companies across those seven platforms, re-read every night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things a live scraper structurally cannot give you
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to write about, because it took me a while to see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You cannot see a job close if you were not watching when it opened.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A scraper that runs today sees today's postings. It cannot tell you that a company closed 30 roles this month, because "closed" is the absence of something it never recorded. Only an index with history has that, and it is the difference between a growth signal and a churn warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;company              opened30   closed30   net
growing co               +48        -6     +42
shrinking co              +3       -37     -34
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second row is a customer about to churn, or a competitor in trouble, depending on who you are. No amount of scraping harder produces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "First time hiring for X" needs a baseline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now 3,486 of the 11,157 companies are hiring into a department they were not hiring into before. You can only know that by having watched what they hired for previously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;company     opened30   just started hiring for
Bjak           +1230   A1 CEO Office, A1 Engineering, A1 HR
Renuity         +362   Corporate, Field
Mindrift        +274   Creator (Writer)
OpenAI          +270   Applied AI, Communications, Consumer Product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting the contact details, honestly
&lt;/h2&gt;

&lt;p&gt;A hiring signal without a way to reach the company is half a product. The approach I settled on is deliberately narrow: fetch the company's own website and take only what it publishes as a way to be contacted. A role address like &lt;code&gt;info@&lt;/code&gt; or &lt;code&gt;sales@&lt;/code&gt;, a phone number inside a &lt;code&gt;tel:&lt;/code&gt; link, its own social profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No guessed first.last@ patterns, no permutation, no verification service, no named individuals.&lt;/strong&gt; Partly because guessing produces bounces, mostly because a person's work address was published so they could do their job, not so a stranger could buy it in a list.&lt;/p&gt;

&lt;p&gt;That honesty has a price and it shows up as a number: 6,634 of 11,157 companies are reachable this way, with every one of them crawled. If a vendor tells you they have 95% coverage of decision-maker emails, they are permuting names against a mail server, and you are buying a bounce rate.&lt;/p&gt;

&lt;p&gt;Two implementation notes that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tel:&lt;/code&gt; links only.&lt;/strong&gt; My first pass regexed phone-shaped strings out of the page and collected SVG path coordinates, longitudes and unix timestamps. A &lt;code&gt;tel:&lt;/code&gt; href is the publisher asserting "this is a phone number".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role allowlist, not a blocklist.&lt;/strong&gt; Filtering out obvious personal addresses still let &lt;code&gt;sarah@&lt;/code&gt; through. Allowing only a known set of role mailboxes is the version that holds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Absent is not zero
&lt;/h2&gt;

&lt;p&gt;The rule that ended up mattering most: a company we have not crawled yet and a company whose site publishes nothing must never look the same. They send you to two different places. So every row carries &lt;code&gt;contactsHarvested&lt;/code&gt;, and &lt;code&gt;closed30&lt;/code&gt; is &lt;strong&gt;absent&lt;/strong&gt; rather than &lt;code&gt;0&lt;/code&gt; until there is enough history to know the difference between "closed nothing" and "we do not know yet".&lt;/p&gt;

&lt;p&gt;Same for salary: two of the seven platforms publish a band, so 92% of rows have no salary and say so, rather than carrying a plausible guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The index is on Apify: &lt;a href="https://apify.com/glitchbound/b2b-leads-finder" rel="noopener noreferrer"&gt;https://apify.com/glitchbound/b2b-leads-finder&lt;/a&gt;&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;"enteringNewDepartments"&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;"minOpenedLast30"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"withEmailOnly"&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="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;One row per company, with the hiring signal and the contact details. The jobs themselves are a separate Actor if you want the postings instead: &lt;a href="https://apify.com/glitchbound/ats-jobs-search" rel="noopener noreferrer"&gt;https://apify.com/glitchbound/ats-jobs-search&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the seven APIs in the comments. If you are pulling job data today, I am curious what you are using and what breaks.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
      <category>sales</category>
    </item>
    <item>
      <title>Search every Greenhouse, Ashby and Workable job board at once, without a company list</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Wed, 05 Aug 2026 20:12:35 +0000</pubDate>
      <link>https://dev.to/glitchbound/search-every-greenhouse-ashby-and-workable-job-board-at-once-without-a-company-list-o96</link>
      <guid>https://dev.to/glitchbound/search-every-greenhouse-ashby-and-workable-job-board-at-once-without-a-company-list-o96</guid>
      <description>&lt;p&gt;Every guide to scraping job boards starts the same way: "first, get the company's board token." Greenhouse, Ashby and Workable each expose a public JSON API per company, so if you know the token, one HTTP call returns every open role. Clean, documented, no login.&lt;/p&gt;

&lt;p&gt;The problem is the token. There is no directory of them. If you want "senior backend roles in Berlin" across the whole ecosystem, you would need the board token of every company that might be hiring, which is exactly the list you were hoping the data would give you. The per-company APIs answer "what is Stripe hiring?" but not "who is hiring?"&lt;/p&gt;

&lt;p&gt;I run a nightly index that solves the inversion: it already knows 7,131 boards across Greenhouse, Ashby and Workable, and re-reads them every night. 230,508 open roles at the moment I write this. You query it like a search engine instead of crawling anything yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  One call, no company list
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/glitchbound~ats-jobs-search/run-sync-get-dataset-items?token=&lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "title": ["backend engineer*", "platform engineer*"],
    "countries": ["Germany"],
    "workArrangements": ["remote"],
    "postedAfter": "2026-07-01",
    "maxResults": 100
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get one JSON row per job: title, company, location, the real apply URL on the employer's own site, department, posting date, and salary where the employer published one. A trailing &lt;code&gt;*&lt;/code&gt; does prefix matching, a multi-word line is a phrase, and &lt;code&gt;countries&lt;/code&gt; is resolved rather than text-matched, so German roles arrive whether the board wrote &lt;code&gt;Berlin, Germany&lt;/code&gt;, &lt;code&gt;Munich, DE&lt;/code&gt; or &lt;code&gt;DE - Berlin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same thing in Python, with the Apify client:&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;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glitchbound/ats-jobs-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&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;title&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data engineer*&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;countries&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Netherlands&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;Germany&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;seniority&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;senior&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;staff&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;maxResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&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;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_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="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&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;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&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;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is not "just scrape LinkedIn"
&lt;/h2&gt;

&lt;p&gt;LinkedIn scrapers need an account, and accounts get banned. This index reads the same employer-published APIs that job aggregators are invited to read: no login, no cookies, no proxy, and the apply link goes to the employer's own careers page rather than through an aggregator.&lt;/p&gt;

&lt;p&gt;The trade-off is coverage. It holds companies that run their hiring on Greenhouse, Ashby or Workable, which is most of tech, and it does not hold the long tail of companies that only post to LinkedIn. Lever is missing too: their robots.txt blocks the crawler the index is seeded from, and a partial Lever list would be worse than none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I have not seen elsewhere: companies as the row
&lt;/h2&gt;

&lt;p&gt;Flip &lt;code&gt;mode&lt;/code&gt; to &lt;code&gt;companies&lt;/code&gt; and the same index answers a different question: who is hiring hardest right now?&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"companies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hiringLabels"&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;"surging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"growing"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enteringNewDepartments"&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="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;Each row is a company with its open-role count, how many roles it opened in the last 30 and 7 days, and which departments are new this month. A company opening its first sales or security roles has just changed strategy. Because the index sees every board every night, it can also tell you when jobs close, which a live scraper cannot: you cannot see a posting disappear if you were not there when it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;42,894 of the roles are remote; the rest are not, and unlabelled jobs are left unlabelled rather than guessed into a bucket.&lt;/li&gt;
&lt;li&gt;Salary appears on about 8% of jobs, from Ashby only, because Greenhouse and Workable publish no salary field at all.&lt;/li&gt;
&lt;li&gt;The index refreshes nightly, not per-request. Follow the &lt;code&gt;url&lt;/code&gt; field to the employer's page, which is always authoritative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pricing is per job returned, about a dollar per thousand, and a search that matches nothing costs nothing.&lt;/p&gt;

&lt;p&gt;The Actor is here: &lt;a href="https://apify.com/glitchbound/ats-jobs-search" rel="noopener noreferrer"&gt;https://apify.com/glitchbound/ats-jobs-search&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Questions and edge cases welcome in the comments; if a filter you need is missing, say so and I will likely ship it.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>python</category>
      <category>career</category>
    </item>
    <item>
      <title>Workday's job API tells you there are 2,000 jobs, then says 0 on page two</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sun, 02 Aug 2026 12:18:36 +0000</pubDate>
      <link>https://dev.to/glitchbound/workdays-job-api-tells-you-there-are-2000-jobs-then-says-0-on-page-two-2c8e</link>
      <guid>https://dev.to/glitchbound/workdays-job-api-tells-you-there-are-2000-jobs-then-says-0-on-page-two-2c8e</guid>
      <description>&lt;p&gt;Workday is where large enterprises actually post. NVIDIA has 2,000 open roles&lt;br&gt;
there, Salesforce 1,477, Adobe 832. It answers an anonymous POST with no key.&lt;/p&gt;

&lt;p&gt;It also has two behaviours that are not in any documentation you can read&lt;br&gt;
without an account, and both of them fail silently. One of them costs you 98%&lt;br&gt;
of the board without raising anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The number that changes after page one
&lt;/h2&gt;

&lt;p&gt;Ask for the first twenty postings and the response carries a &lt;code&gt;total&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;POST /wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs
{"appliedFacets":{}, "limit":20, "offset":0, "searchText":""}

  20 jobPostings,  total: 2000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask for the next twenty and the count is gone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;offset 20   -&amp;gt;  20 jobPostings,  total: 0
offset 40   -&amp;gt;  20 jobPostings,  total: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not null, not absent. Zero. The postings keep coming; only the count collapses.&lt;/p&gt;

&lt;p&gt;Measured on four enterprise tenants:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tenant&lt;/th&gt;
&lt;th&gt;total at offset 0&lt;/th&gt;
&lt;th&gt;at offset 20&lt;/th&gt;
&lt;th&gt;at offset 40&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NVIDIA&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salesforce&lt;/td&gt;
&lt;td&gt;1477&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adobe&lt;/td&gt;
&lt;td&gt;832&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sony&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;0&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;Same shape every time, so this is Workday and not one tenant's configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that costs you 98% of the board
&lt;/h2&gt;

&lt;p&gt;Here is the loop everyone writes, and it is not a bad loop:&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;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&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="p"&gt;[]&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobPostings&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;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
    &lt;span class="n"&gt;offset&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;posts&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;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;      &lt;span class="c1"&gt;# looks obviously right
&lt;/span&gt;        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On page two &lt;code&gt;page["total"]&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;20 &amp;gt;= 0&lt;/code&gt; is true. The loop exits,&lt;br&gt;
reports no error, and hands back what it has.&lt;/p&gt;

&lt;p&gt;I ran both versions against NVIDIA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;declared total on page one              2000
the obvious loop collected                40      2%
keeping the first total instead         2000    100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty postings out of two thousand, and nothing anywhere says so. No exception,&lt;br&gt;
no warning, no partial-result flag. Just a job board that looks very quiet.&lt;/p&gt;

&lt;p&gt;The fix is one line moved:&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;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&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="p"&gt;[],&lt;/span&gt; &lt;span class="bp"&gt;None&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobPostings&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;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
    &lt;span class="n"&gt;offset&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;posts&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;total&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                &lt;span class="c1"&gt;# the first answer is the only honest one
&lt;/span&gt;        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&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;total&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="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two changes that matter. Keep the first &lt;code&gt;total&lt;/code&gt;. And guard on &lt;code&gt;total&lt;/code&gt; being&lt;br&gt;
truthy, so a board that never reports a usable count still runs to the empty&lt;br&gt;
page instead of stopping immediately.&lt;/p&gt;
&lt;h2&gt;
  
  
  The page size is capped at 20, and 21 is a 400
&lt;/h2&gt;

&lt;p&gt;The other undocumented one. &lt;code&gt;limit&lt;/code&gt; is not a suggestion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;limit=20  -&amp;gt;  HTTP 200
limit=21  -&amp;gt;  HTTP 400
limit=25  -&amp;gt;  HTTP 400
limit=50  -&amp;gt;  HTTP 400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly twenty. And the failure arrives as a 400, which reads as "your request&lt;br&gt;
body is malformed" rather than "that number is too big", so the natural&lt;br&gt;
reaction is to go looking at your JSON.&lt;/p&gt;

&lt;p&gt;Two thousand postings at twenty a page is a hundred round trips. That is the&lt;br&gt;
price, and there is no parameter that lowers it.&lt;/p&gt;
&lt;h2&gt;
  
  
  You cannot build the URL from a company name
&lt;/h2&gt;

&lt;p&gt;A Workday address carries three separate unknowns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
        ^^^^^^ ^^^                    ^^^^^^^^^^^^^^^^^^^^^^^
        host   data centre            career site name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host label is usually the company, but the data centre is wd1 through&lt;br&gt;
wd12 with no pattern, and the site name is whatever whoever set it up typed.&lt;br&gt;
&lt;code&gt;External_Career_Site&lt;/code&gt;, &lt;code&gt;external_experienced&lt;/code&gt;, &lt;code&gt;SonyGlobalCareers&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;NVIDIAExternalCareerSite&lt;/code&gt;. Guessing across that space is mostly 404s.&lt;/p&gt;

&lt;p&gt;The API path adds a fourth part that the public URL does not show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public   https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
api      https://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/jobs
                                              ^^^^^^^^^^^^^^^
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On all four tenants I checked, that middle segment is the host label again.&lt;br&gt;
Build it with a literal &lt;code&gt;None&lt;/code&gt; in there and you get a &lt;strong&gt;422&lt;/strong&gt;, not a 404, which&lt;br&gt;
at least fails in a way that does not look like a wrong company name.&lt;/p&gt;

&lt;p&gt;So take the careers URL from the customer. It is the one thing they always&lt;br&gt;
have, and it is the only reliable source for all three parts.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the postings look like
&lt;/h2&gt;

&lt;p&gt;Sparse, and worth knowing before you design a schema around 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;"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;"Distinguished Software Architect - Deep Learning"&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/..."&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;"JR2001234"&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;Five keys. No department, no employment type, no salary, no description, and&lt;br&gt;
&lt;code&gt;postedOn&lt;/code&gt; is a relative phrase rather than a date.&lt;/p&gt;

&lt;p&gt;Counted across 80 NVIDIA postings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Posted 30+ Days Ago    20
Posted 5 Days Ago      13
Posted 13 Days Ago     13
Posted 2 Days Ago      12
Posted Today            5
Posted Yesterday        3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not parse that into a timestamp. A quarter of them are "30+ Days Ago",&lt;br&gt;
which has no date in it at all, and a timestamp you invented is worse than a&lt;br&gt;
string you kept: the string is obviously approximate and the timestamp is not.&lt;br&gt;
Give it its own field and let the consumer decide.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Measured on 2026-08-02. Four tenants carried a usable count: NVIDIA,&lt;br&gt;
Salesforce, Adobe and Sony. Dell reported 0 at offset 0 as well, which is the&lt;br&gt;
third trap below rather than a fifth data point. I&lt;br&gt;
maintain &lt;a href="https://apify.com/glitchbound" rel="noopener noreferrer"&gt;a set of ATS scrapers&lt;/a&gt; that normalise&lt;br&gt;
nine of these systems into one row shape, which is how I ran into all of it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>career</category>
    </item>
    <item>
      <title>Ashby's isRemote flag is true for hybrid roles. I checked 1,668 postings.</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sun, 02 Aug 2026 11:59:21 +0000</pubDate>
      <link>https://dev.to/glitchbound/ashbys-isremote-flag-is-true-for-hybrid-roles-i-checked-1668-postings-79</link>
      <guid>https://dev.to/glitchbound/ashbys-isremote-flag-is-true-for-hybrid-roles-i-checked-1668-postings-79</guid>
      <description>&lt;p&gt;If you build anything on Ashby's public job board API, there is one field that&lt;br&gt;
will quietly ruin your filter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isRemote&lt;/code&gt; is a boolean. It is not the boolean you think.&lt;/p&gt;

&lt;p&gt;I pulled every posting from ten real Ashby boards on 2026-08-02, 1,668 in&lt;br&gt;
total, and counted:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;postings&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isRemote: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,072&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;workplaceType: "Remote"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;295&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filter on the boolean and you get three and a half times as many roles as are&lt;br&gt;
actually remote.&lt;/p&gt;
&lt;h2&gt;
  
  
  It is not noise, it is a definition
&lt;/h2&gt;

&lt;p&gt;Cross-tabulating the two fields across nine of those boards, 1,637 postings,&lt;br&gt;
leaves no ambiguity at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workplaceType    isRemote    postings
Hybrid           true             776
Remote           true             295
OnSite           false            271
(absent)         false            295
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero exceptions in 1,637 rows. &lt;code&gt;isRemote&lt;/code&gt; is exactly&lt;br&gt;
&lt;code&gt;workplaceType != "OnSite"&lt;/code&gt;, with an absent type counting as on-site.&lt;/p&gt;

&lt;p&gt;So the field is honest about what it computes and misleading about what it is&lt;br&gt;
called. It means &lt;strong&gt;not fully on-site&lt;/strong&gt;. Hybrid roles, 47% of everything I&lt;br&gt;
pulled, all carry it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The per-company spread is the part that bites
&lt;/h2&gt;

&lt;p&gt;An average of 3.6x would be survivable. The variance is not:&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;postings&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;isRemote&lt;/code&gt; true&lt;/th&gt;
&lt;th&gt;actually Remote&lt;/th&gt;
&lt;th&gt;overstated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;openai&lt;/td&gt;
&lt;td&gt;754&lt;/td&gt;
&lt;td&gt;485&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.2x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;replit&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ramp&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;7.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notion&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cohere&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vanta&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;1.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cursor&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sierra&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;linear&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Linear is honest by accident: nearly all its roles genuinely are remote, so the&lt;br&gt;
two fields agree. Notion is the opposite extreme. It has &lt;strong&gt;zero&lt;/strong&gt; roles marked&lt;br&gt;
Remote and 74 postings flagged &lt;code&gt;isRemote: true&lt;/code&gt;, because Notion is a hybrid&lt;br&gt;
company and every hybrid role trips the flag.&lt;/p&gt;

&lt;p&gt;Which means any sanity check you run on one company can pass and tell you&lt;br&gt;
nothing. Test on Linear and the field looks perfect. Test on Notion and every&lt;br&gt;
result is wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three states, not two
&lt;/h2&gt;

&lt;p&gt;The real fix is not a better boolean, it is admitting that this is not a&lt;br&gt;
boolean question. &lt;code&gt;workplaceType&lt;/code&gt; has three values, and the third one is the&lt;br&gt;
one people actually argue about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote   295
Hybrid   776
OnSite   271
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Collapsing that into yes-or-no has to lie in one direction or the other.&lt;br&gt;
Ashby's boolean lies toward remote; a boolean built the other way would tell&lt;br&gt;
776 people a hybrid role is on-site, which is equally wrong and would just&lt;br&gt;
annoy a different group.&lt;/p&gt;

&lt;p&gt;If you are normalising job data across sources, carry all three. A row that&lt;br&gt;
says &lt;code&gt;Hybrid&lt;/code&gt; is more useful than a row that says &lt;code&gt;true&lt;/code&gt;, and it is the only&lt;br&gt;
version you can aggregate honestly.&lt;/p&gt;
&lt;h2&gt;
  
  
  The 295 with nothing set
&lt;/h2&gt;

&lt;p&gt;There is a fourth group worth knowing about: 295 postings, 18% of that&lt;br&gt;
cross-tab, carry no &lt;code&gt;workplaceType&lt;/code&gt; at all. They all come back &lt;code&gt;isRemote: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is a company that never filled the field, being reported as not remote. It&lt;br&gt;
might be. Nobody said so. If you are ranking or filtering, treat an absent&lt;br&gt;
workplace type as unknown rather than as a no, because the API has already made&lt;br&gt;
that judgement for you and it had nothing to go on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting the field at all
&lt;/h2&gt;

&lt;p&gt;One more trap, unrelated but in the same request. Ashby omits compensation&lt;br&gt;
entirely unless you ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /posting-api/job-board/ramp                            no compensation key
GET /posting-api/job-board/ramp?includeCompensation=true   "$211.4K - $290.6K"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had that parameter wired to a description flag in my own client for a while,&lt;br&gt;
which meant every run that did not ask for descriptions silently dropped&lt;br&gt;
salary. Worth reading a response you are sure you already understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually do
&lt;/h2&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;workplace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posting&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Three states, and unknown is not a no.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;wt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;posting&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;workplaceType&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="n"&gt;wt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Remote&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;Hybrid&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;OnSite&lt;/span&gt;&lt;span class="sh"&gt;"&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;wt&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# NOT False, and not isRemote
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines, and it is right on all 1,668.&lt;/p&gt;

&lt;p&gt;None of this is Ashby's fault exactly. &lt;code&gt;isRemote&lt;/code&gt; is doing what it says in the&lt;br&gt;
code that produced it. It is just that the name promises an answer to a&lt;br&gt;
question the data cannot answer, and a boolean will always find someone willing&lt;br&gt;
to believe it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every number here is from the public job board API, no key, and reproducible:&lt;br&gt;
&lt;code&gt;api.ashbyhq.com/posting-api/job-board/&amp;lt;board&amp;gt;?includeCompensation=true&lt;/code&gt;. I&lt;br&gt;
maintain &lt;a href="https://apify.com/glitchbound" rel="noopener noreferrer"&gt;a set of ATS scrapers&lt;/a&gt; that normalise&lt;br&gt;
this across nine systems, which is how I ran into it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>Indeed 403s you with zero bytes. The companies it aggregates publish free APIs.</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Sat, 01 Aug 2026 19:30:44 +0000</pubDate>
      <link>https://dev.to/glitchbound/indeed-403s-you-with-zero-bytes-the-companies-it-aggregates-publish-free-apis-2k46</link>
      <guid>https://dev.to/glitchbound/indeed-403s-you-with-zero-bytes-the-companies-it-aggregates-publish-free-apis-2k46</guid>
      <description>&lt;p&gt;I spent an afternoon measuring which job sources actually answer a request, and&lt;br&gt;
the result was cleaner than I expected: &lt;strong&gt;the aggregators are shut and the&lt;br&gt;
sources they aggregate from are wide open.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below was measured on 2026-08-01 from an ordinary residential&lt;br&gt;
connection with normal browser headers. Not read from documentation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The aggregators
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;indeed.com/jobs          403      0 bytes
glassdoor.com/Job/...    403      0 bytes
ziprecruiter.com         403      0 bytes
upwork.com/nx/search     403      0 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Zero bytes. Cloudflare rejects these before the request reaches an application,&lt;br&gt;
so there is nothing to parse and nothing to be clever about. That is from a&lt;br&gt;
residential address, which is the friendliest IP you can bring. A datacenter&lt;br&gt;
address does worse.&lt;/p&gt;

&lt;p&gt;Reddit is worth a mention because it fails in a more interesting way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reddit.com/r/programming/hot.json    403   189,908 bytes
old.reddit.com/.../hot.json          403   189,908 bytes
reddit.com/r/programming/.rss        200    40,349 bytes   &amp;lt;- once
reddit.com/r/programming/.rss        429         0 bytes   &amp;lt;- and every time after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the 189,908 bytes on a 403. If you check &lt;code&gt;len(response.content)&lt;/code&gt; instead of&lt;br&gt;
&lt;code&gt;response.status_code&lt;/code&gt;, that block page reads as a large successful response. I&lt;br&gt;
have written that bug. It is the most expensive one-line mistake in scraping,&lt;br&gt;
because it does not fail, it lies.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sources
&lt;/h2&gt;

&lt;p&gt;Indeed does not originate job data. Neither does Glassdoor, ZipRecruiter or&lt;br&gt;
LinkedIn Jobs. They aggregate from company career pages, and those career pages&lt;br&gt;
run on applicant tracking systems. Every major one publishes a public JSON API&lt;br&gt;
with no key, because companies &lt;strong&gt;want&lt;/strong&gt; their openings indexed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://boards-api.greenhouse.io/v1/boards/stripe/jobs
200    548 jobs

GET https://api.ashbyhq.com/posting-api/job-board/openai
200    753 jobs

GET https://api.lever.co/v0/postings/palantir?mode=json
200    302 jobs

GET https://api.rippling.com/platform/api/ats/v1/board/rippling/jobs
200    768 jobs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also open: Workable, Recruitee, SmartRecruiters, Personio. No key, no proxy, no&lt;br&gt;
bot wall, and fresher than any aggregator, because an aggregator shows you its&lt;br&gt;
last crawl and this shows you the board.&lt;/p&gt;

&lt;p&gt;One distinction worth making, because these are not all the same thing.&lt;br&gt;
Greenhouse, Lever, Ashby, Recruitee and SmartRecruiters &lt;strong&gt;document these&lt;br&gt;
endpoints publicly as needing no authentication&lt;/strong&gt;. Greenhouse says it plainly:&lt;br&gt;
"Job Board data is publicly available, so authentication is not required for any&lt;br&gt;
GET endpoints." Lever goes further and says published postings "may be scraped&lt;br&gt;
by third parties."&lt;/p&gt;

&lt;p&gt;Rippling and Workable are different. Rippling's developer documentation sits&lt;br&gt;
behind a login, and Workable's documented API is a separate authenticated one.&lt;br&gt;
The endpoints I used for those two are the ones their own public careers pages&lt;br&gt;
call, they answer an anonymous request, and they serve postings a company chose&lt;br&gt;
to publish. But "answers without a key" is not the same claim as "the vendor&lt;br&gt;
documents it as public", and I would rather say which is which than let the&lt;br&gt;
list imply all seven are equivalent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 1: you need the board token, and there is no directory
&lt;/h2&gt;

&lt;p&gt;Every one of those URLs contains a token: &lt;code&gt;stripe&lt;/code&gt;, &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;palantir&lt;/code&gt;. There&lt;br&gt;
is no public mapping from a company to its token. This is the actual reason&lt;br&gt;
people reach for Indeed instead.&lt;/p&gt;

&lt;p&gt;The obvious fix is to fetch the company's careers page and look for an ATS link.&lt;br&gt;
I tried that on 12 companies and it found &lt;strong&gt;6&lt;/strong&gt;. Stripe's careers page is a&lt;br&gt;
JavaScript app that names no board anywhere in the HTML.&lt;/p&gt;

&lt;p&gt;The fix that works is the other way round: &lt;strong&gt;derive candidates from the domain&lt;br&gt;
and verify each against the live API.&lt;/strong&gt;&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;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;//&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="mi"&gt;1&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&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="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&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;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stem&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&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="n"&gt;PROBES&lt;/span&gt; &lt;span class="o"&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;greenhouse&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;https://boards-api.greenhouse.io/v1/boards/{}/jobs&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;lever&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;https://api.lever.co/v0/postings/{}?mode=json&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;ashby&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;https://api.ashbyhq.com/posting-api/job-board/{}&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;recruitee&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;https://{}.recruitee.com/api/offers/&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;workable&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;https://apply.workable.com/api/v1/widget/accounts/{}?details=true&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;rippling&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;https://api.rippling.com/platform/api/ats/v1/board/{}/jobs&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stripe.com&lt;/code&gt; gives &lt;code&gt;stripe&lt;/code&gt;, and &lt;code&gt;boards-api.greenhouse.io/v1/boards/stripe/jobs&lt;/code&gt;&lt;br&gt;
returns 548 jobs. Both methods, measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Resolved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read the careers page for a link&lt;/td&gt;
&lt;td&gt;12 companies&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Derive the token and verify it&lt;/td&gt;
&lt;td&gt;30 companies&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Trap 2: why guessing is sound here, and where it stops being sound
&lt;/h2&gt;

&lt;p&gt;Guessing is only safe if a wrong guess is distinguishable from a right one. I&lt;br&gt;
checked each API with a real token and a deliberately bogus one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;greenhouse   real 200      bogus 404
lever        real 200      bogus 404
ashby        real 200      bogus 404
recruitee    real 200      bogus 404
workable     real 200      bogus 404
rippling     real 200      bogus 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 404 for an unknown board is what makes a 200 &lt;strong&gt;proof&lt;/strong&gt; rather than a maybe.&lt;br&gt;
And a real board with nothing open is a third, distinct state:&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="err"&gt;ashby/deel&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;:[],&lt;/span&gt;&lt;span class="nl"&gt;"apiVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;lever/kraken&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;200&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 &lt;code&gt;404&lt;/code&gt; means no such board, &lt;code&gt;200 []&lt;/code&gt; means the board exists and is empty.&lt;br&gt;
Clean semantics, and they let you tell "wrong token" apart from "not hiring".&lt;/p&gt;

&lt;p&gt;SmartRecruiters is the exception, and it is worth knowing before you trust it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;smartrecruiters  real company Visa            200  {"totalFound":2,   "content":[...]}
smartrecruiters  company with nothing open    200  {"totalFound":0,   "content":[]}
smartrecruiters  company that does not exist  200  {"totalFound":0,   "content":[]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two are byte-identical. The obvious second signal does not rescue it&lt;br&gt;
either: &lt;code&gt;GET /v1/companies/Visa&lt;/code&gt; returns &lt;strong&gt;404&lt;/strong&gt;, for a company whose postings&lt;br&gt;
endpoint returns real jobs. So on SmartRecruiters there is no way to confirm a&lt;br&gt;
guessed id, and the honest move is to require an exact one rather than emit a&lt;br&gt;
confident zero. Company ids there are also case sensitive: &lt;code&gt;Visa&lt;/code&gt; works, &lt;code&gt;visa&lt;/code&gt;&lt;br&gt;
does not.&lt;/p&gt;

&lt;p&gt;This is the same shape as a family of bugs I keep meeting: an absence being&lt;br&gt;
returned as an answer. RDAP does it too, where a 404 means "no server for this&lt;br&gt;
TLD" and gets read as "domain available".&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 3: descriptions cost 18x, and change the schema
&lt;/h2&gt;

&lt;p&gt;Greenhouse takes &lt;code&gt;?content=true&lt;/code&gt;. Same board, measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;boards-api.greenhouse.io/v1/boards/figma/jobs                  111 KB
boards-api.greenhouse.io/v1/boards/figma/jobs?content=true    1.97 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen times the payload. What is less obvious is that &lt;code&gt;departments&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;offices&lt;/code&gt; &lt;strong&gt;only exist when content is on&lt;/strong&gt;, so if you want the department of a&lt;br&gt;
role you are paying for every description whether you wanted them or not.&lt;/p&gt;

&lt;p&gt;Rippling goes the other way: its board API returns five fields and no&lt;br&gt;
description at any setting. If you build a uniform schema across platforms, that&lt;br&gt;
field is empty because the source has none, not because you missed it. Worth&lt;br&gt;
saying in your output rather than shipping a null that reads as a bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you get for the trouble
&lt;/h2&gt;

&lt;p&gt;One request per company, no key, no proxy, and data that has not been through&lt;br&gt;
somebody else's crawl schedule. Stripe's 548 roles, Databricks' 803, OpenAI's&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Titles, locations, departments, offices, descriptions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And on Ashby, salary. Of the seven JSON sources it is the only one that&lt;br&gt;
publishes a compensation band, and it hides it behind a parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /posting-api/job-board/ramp                             no compensation key
GET /posting-api/job-board/ramp?includeCompensation=true    "$211.4K - $290.6K"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Ramp and OpenAI posting carries a real band. Notion and Linear return the&lt;br&gt;
field full of nulls, which is a company choosing not to publish rather than a&lt;br&gt;
missing feature, and the two are worth telling apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction, added after publishing.&lt;/strong&gt; I first wrote that Ashby was the only&lt;br&gt;
one of the eight sources here with salary. That is wrong: Personio, the XML one,&lt;br&gt;
publishes it too, and in a better shape. Ashby gives you a formatted string,&lt;br&gt;
&lt;code&gt;"$211.4K - $290.6K"&lt;/code&gt;. Personio gives you the numbers:&lt;br&gt;
&lt;/p&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;salaryInformation&amp;gt;&amp;lt;min&amp;gt;&lt;/span&gt;45000.00&lt;span class="nt"&gt;&amp;lt;/min&amp;gt;&amp;lt;max&amp;gt;&lt;/span&gt;55000.00&lt;span class="nt"&gt;&amp;lt;/max&amp;gt;&amp;lt;/salaryInformation&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;currencyCode&amp;gt;&lt;/span&gt;EUR&lt;span class="nt"&gt;&amp;lt;/currencyCode&amp;gt;&amp;lt;currencySymbol&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;#8364;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/currencySymbol&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structured min, max and currency, which is what you want if you are sorting or&lt;br&gt;
filtering rather than displaying. Personio is a European mid-market system, so&lt;br&gt;
this is worth knowing if that is your segment. I had this parameter wired&lt;br&gt;
to the description flag in my own client and was silently dropping salary on&lt;br&gt;
every run that did not ask for descriptions, which is a good argument for&lt;br&gt;
reading a response you think you already understand.&lt;/p&gt;

&lt;p&gt;The aggregators are not gatekeepers of this data. They are readers of it, same&lt;br&gt;
as you, and they are the only ones who have to be let in.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every endpoint in this post is public and the measurements stand on their own,&lt;br&gt;
so nothing here needs a product. I am packaging the eight clients and the domain&lt;br&gt;
resolver as Actors on the Apify Store under&lt;br&gt;
&lt;a href="https://apify.com/glitchbound" rel="noopener noreferrer"&gt;glitchbound&lt;/a&gt;, which is also where my other&lt;br&gt;
scrapers live if this is the kind of thing you use.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webscraping</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Google's News API has been gone since 2016, and people still search for it</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:44:36 +0000</pubDate>
      <link>https://dev.to/glitchbound/googles-news-api-has-been-gone-since-2016-and-people-still-search-for-it-3544</link>
      <guid>https://dev.to/glitchbound/googles-news-api-has-been-gone-since-2016-and-people-still-search-for-it-3544</guid>
      <description>&lt;p&gt;Search for "google news api" and a striking share of the results are people&lt;br&gt;
looking for something that no longer exists. Google deprecated the News Search&lt;br&gt;
API in May 2011 as part of a wider AJAX Search API cull, kept it limping until&lt;br&gt;
the final shutdown in February 2016, and never shipped a replacement. A decade&lt;br&gt;
later the searches are still there.&lt;/p&gt;

&lt;p&gt;What almost nobody mentions is that Google News still exposes a perfectly good&lt;br&gt;
public endpoint. It is just an RSS feed rather than a JSON API, it needs no key,&lt;br&gt;
and it has quietly outlived the API that was supposed to be the real product.&lt;/p&gt;
&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://news.google.com/rss/search?q=&amp;lt;query&amp;gt;&amp;amp;hl=en-US&amp;amp;gl=US&amp;amp;ceid=US:en
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That is the whole thing. Swap &lt;code&gt;hl&lt;/code&gt;, &lt;code&gt;gl&lt;/code&gt; and &lt;code&gt;ceid&lt;/code&gt; for a different language and&lt;br&gt;
country and you get that edition instead. There is no key, no quota page, and no&lt;br&gt;
sign-up.&lt;/p&gt;

&lt;p&gt;The catches are real but small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is RSS, so you get XML.&lt;/strong&gt; Title, link, publication date, source name and
a short description. No article body, and no way to ask for one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The links are Google redirects&lt;/strong&gt;, not publisher URLs. You have to follow them
if you want the destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The publisher name lives in a nested &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; element&lt;/strong&gt;, not where a normal
feed parser looks for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roughly 100 items per query.&lt;/strong&gt; Deeper history is not available through it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a reason to go and pay for a news API. It is a reason to write&lt;br&gt;
about forty lines once.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two sources are better than one
&lt;/h2&gt;

&lt;p&gt;Bing News publishes its own RSS in the same shape at a different URL. Running&lt;br&gt;
both and merging costs one extra request and meaningfully changes what you see,&lt;br&gt;
because the two indexes disagree about what is worth surfacing.&lt;/p&gt;

&lt;p&gt;Here is a real run over both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 articles
sources: {'google:semiconductor export controls': 8,
          'bing:semiconductor export controls': 8}

2026-07-09  Just Security          It Takes More Than Two to Tango: Creating...
2026-07-20  Reuters                China considers tighter export controls on...
2026-03-24  CSIS                   China's Localization Drive in Semiconductors...
2026-07-28  EU Today               China's Domestic DUV Push Puts ASML's Export...
2026-06-24  Purdue University      Intellectual Property Protection and Export...
2025-11-20  Cfr.org                Protecting the Foundation: Strengthening...
2026-07-19  The Motley Fool        Semiconductor Trade Statistics: U.S. Imports...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row carries &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;publisher&lt;/code&gt;, &lt;code&gt;publishedAt&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;source&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt; and &lt;code&gt;guid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing worth knowing if you parse Bing's feed yourself. It carries the&lt;br&gt;
publisher in &lt;code&gt;&amp;lt;News:Source&amp;gt;&lt;/code&gt;, and declares that prefix as&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xmlns:News="https://www.bing.com/news/search?q=&amp;lt;your query&amp;gt;&amp;amp;format=RSS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The namespace URI is the request URL, so it changes on every query and no fixed&lt;br&gt;
namespace constant will ever match it. Match on the local element name instead.&lt;br&gt;
I found this the way you would expect: &lt;code&gt;publisher&lt;/code&gt; was null on exactly the Bing&lt;br&gt;
half of a two-source run while the value was sitting in the feed the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;ACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glitchbound~news-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;API&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;headlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;searchQueries&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;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxArticlesPerSource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;per_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&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;en&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;includeBingNews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dedupeHeadlines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&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="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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/acts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACTOR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&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;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;maxTotalChargeUsd=0.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="n"&gt;data&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# A feed that fails comes back as a row with an `error` field rather than
&lt;/span&gt;    &lt;span class="c1"&gt;# failing the run, and those are not charged.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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;r&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;headlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;semiconductor export controls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Fields can be absent on any given row, so read them defensively rather
&lt;/span&gt;    &lt;span class="c1"&gt;# than subscripting. Feeds are not schemas.
&lt;/span&gt;    &lt;span class="n"&gt;when&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;publishedAt&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="sh"&gt;""&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="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;who&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;publisher&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;28&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;who&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&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;title&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs &lt;a href="https://apify.com/glitchbound/news-scraper" rel="noopener noreferrer"&gt;News Scraper&lt;/a&gt; on&lt;br&gt;
&lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt;, which is the same forty lines plus the parts that are&lt;br&gt;
tedious rather than hard: both feeds, the nested publisher element, the date&lt;br&gt;
normalisation, and deduplication.&lt;/p&gt;

&lt;p&gt;If you would rather own the code, the endpoint above is public and this post has&lt;br&gt;
told you everything you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplication is most of the value
&lt;/h2&gt;

&lt;p&gt;One wire story appears on dozens of syndicating domains, and Google and Bing will&lt;br&gt;
both hand you several copies. A hundred raw results is frequently thirty stories.&lt;/p&gt;

&lt;p&gt;Dedupe on the URL first, then on a normalised headline, because the same Reuters&lt;br&gt;
piece appears under slightly different titles across outlets. Doing it on&lt;br&gt;
headline alone merges genuinely different stories that share a phrasing; doing it&lt;br&gt;
on URL alone leaves most of the duplication in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this does and does not fit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; brand and competitor monitoring, tracking a topic across outlets,&lt;br&gt;
building a corpus of headlines, seeing which publishers cover a beat, feeding a&lt;br&gt;
summarisation pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does not fit:&lt;/strong&gt; anything needing article text, anything needing more than a few&lt;br&gt;
months of history, or academic archival work. For those you want a licensed&lt;br&gt;
provider, and the honest answer is that no free endpoint replaces one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;If you write it yourself, nothing beyond your own hosting.&lt;/p&gt;

&lt;p&gt;Through the Actor it is $1.50 per 1,000 articles on the free tier, so a daily run&lt;br&gt;
pulling 50 articles is about seven cents a month, plus a $0.02 Actor-start event&lt;br&gt;
per run. Duplicates that get dropped are not charged, and neither are feeds that&lt;br&gt;
fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wider point
&lt;/h2&gt;

&lt;p&gt;An enormous amount of "you need an API for this" is habit. Google News, Apple's&lt;br&gt;
app charts, the SEC, the World Bank, the ECB and Open Food Facts all publish&lt;br&gt;
usable public endpoints that need no key and get very little attention, largely&lt;br&gt;
because there is no vendor with an incentive to tell you about them.&lt;/p&gt;

&lt;p&gt;Worth checking before you pay for access to public data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Runnable examples are here:&lt;br&gt;
&lt;a href="https://github.com/danielhagever/apify-data-actors" rel="noopener noreferrer"&gt;github.com/danielhagever/apify-data-actors&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>datascience</category>
    </item>
    <item>
      <title>WHOIS is gone, RDAP replaced it, and a 404 does not mean what you think</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:02:58 +0000</pubDate>
      <link>https://dev.to/glitchbound/whois-is-gone-rdap-replaced-it-and-a-404-does-not-mean-what-you-think-3alh</link>
      <guid>https://dev.to/glitchbound/whois-is-gone-rdap-replaced-it-and-a-404-does-not-mean-what-you-think-3alh</guid>
      <description>&lt;p&gt;If you last touched domain lookups a few years ago, the thing you remember is&lt;br&gt;
WHOIS: a plaintext protocol on port 43 that returned an unparseable wall of text&lt;br&gt;
in a different shape from every registry.&lt;/p&gt;

&lt;p&gt;It is effectively gone. RDAP replaced it, it is RFC 9083, it returns JSON, and&lt;br&gt;
every gTLD registry is required to run one. This is one of the rare cases where&lt;br&gt;
the standards process produced something strictly better and nobody noticed.&lt;/p&gt;

&lt;p&gt;Here is what a lookup looks like now, what you can actually get out of it after&lt;br&gt;
GDPR, and the one trap that had my own tool confidently reporting registered&lt;br&gt;
domains as free to buy.&lt;/p&gt;
&lt;h2&gt;
  
  
  What RDAP gives you, and what it does not
&lt;/h2&gt;

&lt;p&gt;Post-GDPR, RDAP returns no registrant personal data for virtually every TLD. If&lt;br&gt;
you are looking for who owns a domain, stop: that information is not there and&lt;br&gt;
no legitimate source has it.&lt;/p&gt;

&lt;p&gt;What is there is the operational picture, and that turns out to be the more&lt;br&gt;
useful half:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;registrar, registration date, expiry date, status codes, DNSSEC&lt;/li&gt;
&lt;li&gt;whether the name is locked against transfer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combine that with a DNS lookup and you get the thing people actually want, which&lt;br&gt;
is what a company runs on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;domain        registrar                expires       age mail       dns         dmarc
stripe.com    SafeNames Ltd.           2027-09-11   30.9 google     aws-route53 reject
linear.app    CloudFlare, Inc.         2030-05-09    8.2 google     cloudflare  reject
vercel.com    Amazon Registrar, Inc.   2029-10-04   26.8 google     vercel      quarantine
github.io     -                        -               - -          aws-route53 -
angel.co      -                        -               - google     cloudflare  reject
notion.so     -                        -               - -          cloudflare  quarantine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real run. Mail provider and DNS provider come from MX and NS records,&lt;br&gt;
DMARC policy from a TXT lookup at &lt;code&gt;_dmarc.&amp;lt;domain&amp;gt;&lt;/code&gt;. For sales qualification,&lt;br&gt;
security auditing or competitive research, those three columns are worth more&lt;br&gt;
than any registrant field ever was.&lt;/p&gt;

&lt;p&gt;Note the bottom three rows have DNS but no registrar and no expiry. Hold that&lt;br&gt;
thought.&lt;/p&gt;
&lt;h2&gt;
  
  
  The code
&lt;/h2&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;ACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glitchbound~whois-domain-lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;API&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;includeDns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recordTypes&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&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;MX&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;NS&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;TXT&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;includeEmailSecurity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&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="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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/acts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACTOR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&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;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;maxTotalChargeUsd=0.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="n"&gt;data&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# A domain that could not be looked up comes back as a row with an `error`
&lt;/span&gt;    &lt;span class="c1"&gt;# field rather than failing the run, and those are not charged.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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;r&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stripe.com&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;linear.app&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;vercel.com&lt;/span&gt;&lt;span class="sh"&gt;"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&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;mailProvider&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&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;dnsProvider&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; dmarc=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&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;dmarcPolicy&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That runs an &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; Actor I maintain,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/whois-domain-lookup" rel="noopener noreferrer"&gt;WHOIS Domain Lookup&lt;/a&gt;, because the&lt;br&gt;
tedious parts are the RDAP bootstrap, the vcard unpacking and the provider&lt;br&gt;
fingerprinting rather than the HTTP call. If you would rather do it yourself,&lt;br&gt;
&lt;code&gt;https://rdap.org/domain/&amp;lt;name&amp;gt;&lt;/code&gt; is the whole API and it needs no key.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap: a 404 is ambiguous
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rdap.org&lt;/code&gt; is a router. It reads IANA's bootstrap registry, finds the RDAP&lt;br&gt;
server for your TLD, and redirects. A 404 back from it looks like it means one&lt;br&gt;
thing: no registry holds this name, therefore the domain is available.&lt;/p&gt;

&lt;p&gt;That is what my tool assumed. It was wrong, and the failure is ugly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rdap.org/domain/github.io    404
rdap.org/domain/notion.so    404
rdap.org/domain/segment.io   404
rdap.org/domain/angel.co     404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four are registered. All four returned 404.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;strong&gt;IANA's bootstrap does not cover every TLD.&lt;/strong&gt; It lists RDAP&lt;br&gt;
services for around 1,200 of them, and some of the most common startup TLDs are&lt;br&gt;
not in there:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.io   .co   .de   .me   .so   .gg   .sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;For those, &lt;code&gt;rdap.org&lt;/code&gt; has nowhere to send the query, so it 404s for every name&lt;br&gt;
under them, registered or not. My Actor turned that into &lt;code&gt;isAvailable: true&lt;/code&gt; and&lt;br&gt;
would have told you &lt;code&gt;github.io&lt;/code&gt; was free to register.&lt;/p&gt;

&lt;p&gt;On a tool whose headline feature is availability checking, that is the worst&lt;br&gt;
possible way to be wrong: confident, plausible, and acted upon. Nobody&lt;br&gt;
double-checks a clear answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, in two parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask whether there is a registry at all.&lt;/strong&gt; Fetch&lt;br&gt;
&lt;code&gt;https://data.iana.org/rdap/dns.json&lt;/code&gt; once, build the set of TLDs that have an&lt;br&gt;
RDAP service, and only treat a 404 as "available" when the TLD is in it.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rdap_tlds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;d&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://data.iana.org/rdap/dns.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&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="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services&lt;/span&gt;&lt;span class="sh"&gt;"&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;entry&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Let DNS settle the rest.&lt;/strong&gt; A name with NS records has been delegated by the&lt;br&gt;
registry, and a registry does not delegate a name nobody registered. So for the&lt;br&gt;
TLDs with no RDAP, NS records prove registration even though RDAP cannot.&lt;/p&gt;

&lt;p&gt;The reverse does not hold, and this is the part worth being careful about: a&lt;br&gt;
registered domain can have no NS at all. So "no NS and no RDAP" is not&lt;br&gt;
"available", it is &lt;strong&gt;unknown&lt;/strong&gt;, and it should be returned as null with an&lt;br&gt;
explanation rather than as a guess. A null is a fact the caller can handle. A&lt;br&gt;
plausible wrong answer is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this generalises
&lt;/h2&gt;

&lt;p&gt;The bug was not really about RDAP. It was about reading an absence as a&lt;br&gt;
definite answer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;404&lt;/code&gt; means "I have nothing for you". Whether that is because the thing does not&lt;br&gt;
exist, or because you asked somewhere that was never going to know, is not in&lt;br&gt;
the response. Any time you convert a missing result into a positive claim, check&lt;br&gt;
that you actually asked something capable of answering.&lt;/p&gt;

&lt;p&gt;I have now hit the same shape four times in a month in different tools: an API&lt;br&gt;
silently ignoring a filter it does not support, a page parser taking the first&lt;br&gt;
number in a plausible range, this, and crt.sh, which answers 404 when it is&lt;br&gt;
refusing traffic and reports "nothing found" as an empty list with a 200. All of&lt;br&gt;
them produced output that looked completely normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Charged per domain returned, at $2.00 per 1,000 on the free tier, so a hundred&lt;br&gt;
domains is 20 cents plus a $0.02 Actor-start event. Domains removed by the&lt;br&gt;
built-in filters, like &lt;code&gt;onlyRegistered&lt;/code&gt; or &lt;code&gt;expiringWithinDays&lt;/code&gt;, are not charged,&lt;br&gt;
and neither are lookups that fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Some ccTLDs run thin RDAP servers that return fewer fields. You get whatever
the registry publishes rather than an error.&lt;/li&gt;
&lt;li&gt;DNS answers are returned sorted. Resolvers round-robin, and unsorted output
makes a scheduled run look like it changed every time when nothing did.&lt;/li&gt;
&lt;li&gt;Availability comes from the registry itself, not a reseller API, so there is no
incentive for anyone to tell you a name is taken when it is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Runnable examples are here:&lt;br&gt;
&lt;a href="https://github.com/danielhagever/apify-data-actors" rel="noopener noreferrer"&gt;github.com/danielhagever/apify-data-actors&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every example input in that repo is generated from the Actor's own input schema,&lt;br&gt;
so it is valid by construction rather than by my memory of the field names.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The App Store top charts are a public API, and nobody archives them</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Thu, 30 Jul 2026 16:19:14 +0000</pubDate>
      <link>https://dev.to/glitchbound/the-app-store-top-charts-are-a-public-api-and-nobody-archives-them-n7n</link>
      <guid>https://dev.to/glitchbound/the-app-store-top-charts-are-a-public-api-and-nobody-archives-them-n7n</guid>
      <description>&lt;p&gt;Apple's top charts are one of the few genuinely useful public rankings left.&lt;br&gt;
They are free, they need no key, and they update daily.&lt;/p&gt;

&lt;p&gt;They also have one property that makes them frustrating: &lt;strong&gt;there is no history&lt;/strong&gt;.&lt;br&gt;
The feed shows you today. Yesterday is gone. If you want to know whether an app&lt;br&gt;
climbed or slid, or which category a publisher quietly took over last month, that&lt;br&gt;
data does not exist anywhere unless somebody was writing it down.&lt;/p&gt;

&lt;p&gt;Nobody is writing it down, which means the archive is worth more than the feed.&lt;/p&gt;

&lt;p&gt;This post covers both halves: reading the charts today, and the small amount of&lt;br&gt;
work it takes to have a year of history in a year's time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The feed
&lt;/h2&gt;

&lt;p&gt;Apple's RSS feeds sit at a predictable URL per country, chart type and category.&lt;br&gt;
No key, no login, and no bot wall. That is unusual enough in 2026 to be worth&lt;br&gt;
saying out loud.&lt;/p&gt;

&lt;p&gt;What is awkward is the shape. Each entry buries the app id under&lt;br&gt;
&lt;code&gt;id.attributes["im:id"]&lt;/code&gt;, the price under &lt;code&gt;im:price.attributes&lt;/code&gt;, the developer&lt;br&gt;
under &lt;code&gt;im:artist.label&lt;/code&gt;, and the icon in an array where you want the last&lt;br&gt;
element. Parsing it correctly is 30 lines of nested &lt;code&gt;.get()&lt;/code&gt; calls that you write&lt;br&gt;
once and never enjoy.&lt;/p&gt;

&lt;p&gt;I put that in an &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; Actor,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/app-charts-scraper" rel="noopener noreferrer"&gt;App Store Top Charts Scraper&lt;/a&gt;,&lt;br&gt;
so I could stop rewriting it. One HTTP call runs it and returns flat rows.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;ACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glitchbound~app-charts-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;API&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;top_charts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;charts&lt;/span&gt;&lt;span class="o"&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;top-free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;countries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;categories&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&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="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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/acts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACTOR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&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;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;maxTotalChargeUsd=0.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="n"&gt;data&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# A country or chart that fails comes back as a row with an `error` field
&lt;/span&gt;    &lt;span class="c1"&gt;# rather than failing the run, and those are not charged. Split them out or a
&lt;/span&gt;    &lt;span class="c1"&gt;# run that fetched nothing still looks successful.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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;r&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;


&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;top_charts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&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;gb&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;de&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;charts&lt;/span&gt;&lt;span class="o"&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;top-free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;:&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank&lt;/span&gt;&lt;span class="sh"&gt;"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  #&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;developer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output, from an actual run, top of the German chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DE  #1   Claude by Anthropic                        Anthropic PBC
DE  #2   ChatGPT                                    OpenAI OpCo, LLC
DE  #3   Nect Wallet                                Nect
DE  #4   Google Gemini                              Google
DE  #5   EasyPark parken - Park App                 Easy Park AS
DE  #6   AusweisApp Bund                            Governikus GmbH &amp;amp; Co. KG
DE  #7   Mein dm                                    dm-drogerie markt GmbH +
DE  #8   Post &amp;amp; DHL                                 Deutsche Post AG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That German top ten is itself the argument for archiving. Four of those eight are&lt;br&gt;
government or logistics apps that nobody would predict from the US chart, and the&lt;br&gt;
top two are AI assistants that were not in this chart at all two years ago. You&lt;br&gt;
cannot see either of those facts in a single pull.&lt;/p&gt;

&lt;p&gt;Every row also carries &lt;code&gt;appId&lt;/code&gt;, &lt;code&gt;bundleId&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;isFree&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;genre&lt;/code&gt;, &lt;code&gt;releaseDate&lt;/code&gt;, &lt;code&gt;icon&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt;, so a chart pull doubles as an app&lt;br&gt;
metadata pull.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that is actually valuable
&lt;/h2&gt;

&lt;p&gt;One pull tells you the ranking today, which you could have read off your phone.&lt;br&gt;
The interesting queries all need two pulls separated by time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which apps entered the top 20 this week&lt;/li&gt;
&lt;li&gt;Which publisher gained the most chart slots this month&lt;/li&gt;
&lt;li&gt;How long a given app has held its position&lt;/li&gt;
&lt;li&gt;Whether a competitor's launch actually moved anything&lt;/li&gt;
&lt;li&gt;Which categories are churning and which are frozen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is answerable from the feed. All of it is answerable from a table&lt;br&gt;
you append to daily.&lt;/p&gt;

&lt;p&gt;So the whole trick is: run it on a schedule and never delete anything.&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;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;top_charts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;charts&lt;/span&gt;&lt;span class="o"&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;top-free&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;top-paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;limit&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="n"&gt;stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&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="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seconds&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capturedAt&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="n"&gt;stamp&lt;/span&gt;          &lt;span class="c1"&gt;# the feed has no timestamp; add your own
# append to wherever you keep things: Postgres, SQLite, a CSV, a dataset
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Apify you get this without writing the storage layer: put the Actor on a&lt;br&gt;
Schedule and every run appends to the same dataset, each with its own timestamp.&lt;br&gt;
Three months later you have something nobody sells.&lt;/p&gt;

&lt;p&gt;One detail that matters if you build this yourself: &lt;strong&gt;the feed carries no&lt;br&gt;
timestamp of its own.&lt;/strong&gt; If you do not stamp each capture at write time, a year&lt;br&gt;
later you have a pile of rankings and no way to order them. Stamp it on the way&lt;br&gt;
in, not on the way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Charged per chart entry returned. A 100-app chart is 100 results, so at $3.00 per&lt;br&gt;
1,000 on the free tier a daily 100-app capture for one country is about 30 cents&lt;br&gt;
a month, plus a $0.02 Actor-start event per run. Countries and chart types&lt;br&gt;
multiply that, so pick the markets you actually care about rather than sweeping&lt;br&gt;
all of them.&lt;/p&gt;

&lt;p&gt;Rows that come back as errors are not charged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats worth stating plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apple only.&lt;/strong&gt; Google Play publishes no equivalent public chart feed. Anything
claiming a Play "top chart" API is scraping a rendered page, which breaks on
redesign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Charts are per country, per chart type, per category.&lt;/strong&gt; An app can be absent
from &lt;code&gt;top-free&lt;/code&gt; while sitting high in &lt;code&gt;top-grossing&lt;/code&gt;. "Not in the chart" is
always relative to which chart you asked for.&lt;/li&gt;
&lt;li&gt;Apple caps a feed at 200 entries. Beyond that the answer does not exist in the
feed.&lt;/li&gt;
&lt;li&gt;Rankings are Apple's, computed however Apple computes them. This reads them; it
does not model them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Runnable examples, including this one, are here:&lt;br&gt;
&lt;a href="https://github.com/danielhagever/apify-data-actors" rel="noopener noreferrer"&gt;github.com/danielhagever/apify-data-actors&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every example input in that repo is generated from the Actor's own input schema,&lt;br&gt;
so they are valid by construction rather than by my memory of the field names.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>ios</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Find startups the day they raise, before the press release, with 30 lines of Python</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Thu, 30 Jul 2026 16:16:48 +0000</pubDate>
      <link>https://dev.to/glitchbound/find-startups-the-day-they-raise-before-the-press-release-with-30-lines-of-python-46o6</link>
      <guid>https://dev.to/glitchbound/find-startups-the-day-they-raise-before-the-press-release-with-30-lines-of-python-46o6</guid>
      <description>&lt;p&gt;Funding announcements are old news by the time you read them. There's usually a&lt;br&gt;
gap of weeks between a company closing a round and telling anyone about it. In&lt;br&gt;
that gap, the round is already a matter of public record.&lt;/p&gt;

&lt;p&gt;That's because of Form D. When a US company sells securities in a private&lt;br&gt;
placement, it has to file one with the SEC, generally within 15 days of the first&lt;br&gt;
sale. It's a legal requirement, it's public the moment it's accepted, and almost&lt;br&gt;
nobody reads it.&lt;/p&gt;

&lt;p&gt;So the question is not &lt;em&gt;whether&lt;/em&gt; you can see funding rounds early. It's how to&lt;br&gt;
filter thousands of Form Ds a month down to the ones you care about.&lt;/p&gt;

&lt;p&gt;This post is the working version of that, end to end: ~30 lines of Python, real&lt;br&gt;
output, and the two traps that cost me a couple of hours.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why you can't just list Form Ds
&lt;/h2&gt;

&lt;p&gt;The naive approach is "give me every Form D from the last 90 days." That's&lt;br&gt;
thousands of filings: real estate syndicates, hedge funds, family offices, a&lt;br&gt;
long tail of LLCs. Most of it is not what anyone means by "a startup raised."&lt;/p&gt;

&lt;p&gt;You also can't filter by company name, because &lt;strong&gt;you don't know the company&lt;br&gt;
names yet&lt;/strong&gt;. That's the entire point of the exercise.&lt;/p&gt;

&lt;p&gt;What you want is a full-text search over the &lt;em&gt;body&lt;/em&gt; of the filings, scoped to&lt;br&gt;
Form D and to a recent window. EDGAR supports exactly that, over every filing&lt;br&gt;
since 2001.&lt;/p&gt;
&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;I'm using an &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; Actor I built for EDGAR,&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/sec-filings-scraper" rel="noopener noreferrer"&gt;SEC EDGAR Scraper&lt;/a&gt;, because&lt;br&gt;
it handles the parts of EDGAR that are tedious: CIK resolution, the fair-access&lt;br&gt;
rate limit, XBRL, and the date-range bug I'll get to below. One HTTP call runs it&lt;br&gt;
and returns rows.&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;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;ACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glitchbound~sec-filings-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;API&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;form_d_by_sector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Quote the phrase or EDGAR matches the words separately.
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;searchQuery&lt;/span&gt;&lt;span class="sh"&gt;"&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sector&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formTypes&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;D&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;filedSince&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxSearchResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&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="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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/acts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACTOR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&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;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;maxTotalChargeUsd=0.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="n"&gt;data&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;rows&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# A target the Actor couldn't resolve comes back as a row with an `error`
&lt;/span&gt;    &lt;span class="c1"&gt;# field instead of failing the whole run, and those aren't charged. Count
&lt;/span&gt;    &lt;span class="c1"&gt;# them separately or a run that fetched nothing still looks successful.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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;r&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&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;form_d_by_sector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artificial intelligence&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filingDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;filingDate&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;companyName&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&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;state&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;'&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="nf"&gt;print&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;            &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;filingUrl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;APIFY_TOKEN&lt;/code&gt; from&lt;br&gt;
&lt;a href="https://console.apify.com/settings/integrations" rel="noopener noreferrer"&gt;console.apify.com/settings/integrations&lt;/a&gt;&lt;br&gt;
and run it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Real output
&lt;/h2&gt;

&lt;p&gt;This is an actual run, not a mockup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-07-06  Flo Artificial Intelligence, Inc.  (CIK 0001798621)      CA
            .../data/1798621/000179862126000003/primary_doc.xml
2026-06-29  Artificial Intelligence Financial Services Market Inc.   DE
            .../data/2142624/000214262426000001/primary_doc.xml
2026-06-29  Artificial Intelligence Financial Services Market Inc.   DE
            .../data/2142624/000214262426000002/primary_doc.xml
2026-06-18  Panorama Artificial Intelligence Corp.                   WA
            .../data/2001638/000200163826000001/primary_doc.xml
2026-05-26  Artificial Intelligence Progress Research, LP            --
            .../data/2133240/000213324026000002/primary_doc.xml
2026-05-15  Allied Innovation Artificial Intelligence Fund, LLC      NY
            .../data/2021379/000202137926000001/primary_doc.xml
2026-05-05  Artificial Intelligence Progress Research, LP            --
            .../data/2133240/000213324026000001/primary_doc.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(URLs truncated for width; the real ones are absolute &lt;code&gt;sec.gov&lt;/code&gt; links.)&lt;/p&gt;

&lt;p&gt;Seven filings, each linking straight to the primary document on sec.gov. Every one&lt;br&gt;
is a real entity that filed a real Form D in that window.&lt;/p&gt;

&lt;p&gt;Note that the same company can appear twice, with two separate accession numbers&lt;br&gt;
on 2026-06-29. Those are genuinely two filings, not a duplicate row: an amended Form&lt;br&gt;
D gets its own accession. Dedupe on &lt;code&gt;companyName&lt;/code&gt; only if you actually want to.&lt;/p&gt;

&lt;p&gt;You'll notice something honest about this list: it is heavy on entities with&lt;br&gt;
"Artificial Intelligence" &lt;em&gt;in the company name&lt;/em&gt;, plus a couple of funds. That's&lt;br&gt;
what a full-text phrase search gives you. It matches the text of the filing, and&lt;br&gt;
Form D is a short form without a business description. It's a strong signal,&lt;br&gt;
not a magic sector classifier. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: EDGAR silently ignores a half-open date range
&lt;/h2&gt;

&lt;p&gt;This one cost me real money before I caught it.&lt;/p&gt;

&lt;p&gt;EDGAR's full-text endpoint takes &lt;code&gt;startdt&lt;/code&gt; and &lt;code&gt;enddt&lt;/code&gt; alongside&lt;br&gt;
&lt;code&gt;dateRange=custom&lt;/code&gt;. Send only &lt;code&gt;startdt&lt;/code&gt; and it does not narrow anything, and it&lt;br&gt;
does not tell you either. Measured against the live endpoint, same query, &lt;code&gt;forms=D&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;Parameters&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;th&gt;Oldest result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;startdt&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;2015-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no date params at all&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;2015-07-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;startdt&lt;/code&gt; + &lt;code&gt;enddt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2026-05-05&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;startdt&lt;/code&gt; alone is byte-for-byte identical to sending no filter. If you're paying&lt;br&gt;
per result, you're paying for eleven years of filings outside the window you&lt;br&gt;
asked for, and the response looks perfectly normal.&lt;/p&gt;

&lt;p&gt;If you're calling EDGAR directly: &lt;strong&gt;always send both bounds.&lt;/strong&gt; Default the&lt;br&gt;
missing one: &lt;code&gt;2001-01-01&lt;/code&gt; is the floor for full-text search, and today for the&lt;br&gt;
upper bound. (This is fixed in the Actor as of build 0.1.4; that's why the run&lt;br&gt;
above returns 7 and not 53.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: "recent" isn't the sort you want for anything else
&lt;/h2&gt;

&lt;p&gt;For funding discovery, newest-first is right. For almost every other EDGAR&lt;br&gt;
question it isn't. If you're looking for the filing that &lt;em&gt;matters&lt;/em&gt; on a topic,&lt;br&gt;
recency is noise. Worth knowing before you build the wrong dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;The Actor charges per result: $3.00 per 1,000 on the free tier, so seven filings&lt;br&gt;
is 2.1 cents, plus a one-time $0.02 Actor-start event, so about &lt;strong&gt;4 cents&lt;/strong&gt; for&lt;br&gt;
the run. Paid Apify tiers pay less per result, down to half that on Gold.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;maxTotalChargeUsd=0.50&lt;/code&gt; in the code above is a hard&lt;br&gt;
ceiling the platform enforces. The run stops cleanly at the limit rather than&lt;br&gt;
being killed mid-write, so you can't accidentally spend more than you meant to.&lt;/p&gt;

&lt;p&gt;Rows that come back as errors, such as a ticker that doesn't resolve or a query&lt;br&gt;
EDGAR rejects, aren't charged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;A few directions that work well from here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch a sector on a schedule.&lt;/strong&gt; Run it daily with &lt;code&gt;days=2&lt;/code&gt; and diff against&lt;br&gt;
what you've already seen. New Form Ds show up the day they're accepted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pivot to the company.&lt;/strong&gt; Once a name appears, feed its CIK back in with&lt;br&gt;
&lt;code&gt;includeFinancials: true&lt;/code&gt; to get XBRL figures: revenue, net income, total assets,&lt;br&gt;
straight from what the company tagged, not scraped off a rendered page. A full&lt;br&gt;
20-year history is one request, because it comes from &lt;code&gt;companyfacts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search for other things entirely.&lt;/strong&gt; &lt;code&gt;formTypes: ["8-K"]&lt;/code&gt; with a phrase like&lt;br&gt;
&lt;code&gt;"material weakness"&lt;/code&gt; or &lt;code&gt;"going concern"&lt;/code&gt; finds every company that disclosed it.&lt;br&gt;
&lt;code&gt;formTypes: ["4"]&lt;/code&gt; is insider trades. &lt;code&gt;formTypes: ["13F-HR"]&lt;/code&gt; is what the big&lt;br&gt;
funds hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats worth stating plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form D is a filing, not a validated funding announcement.&lt;/strong&gt; It tells you
securities were sold under a Reg D exemption. It does not confirm a
venture-style round, and the amounts on the form are frequently the maximum
offering rather than what closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-text phrase search matches text, not sectors.&lt;/strong&gt; As the output shows, it
finds companies whose filings contain your phrase, which correlates with the
sector but isn't the same thing. For real classification you'd want to pull the
filing and read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is public disclosure, and that's the whole appeal.&lt;/strong&gt; No login, no key,
no scraping of rendered pages. The SEC asks callers to identify themselves and
stay under 10 requests/second, which the Actor does.&lt;/li&gt;
&lt;li&gt;Not investment advice, obviously.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Full runnable scripts, including this one and a few others, are here:&lt;br&gt;
&lt;a href="https://github.com/danielhagever/apify-data-actors" rel="noopener noreferrer"&gt;github.com/danielhagever/apify-data-actors&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every example input in that repo is generated from the Actor's own input schema,&lt;br&gt;
so they're valid by construction rather than by my memory of the field names.&lt;br&gt;
That's a habit I picked up after the &lt;code&gt;reviewedAt&lt;/code&gt;-vs-&lt;code&gt;date&lt;/code&gt; mistake in a&lt;br&gt;
different script cost me an afternoon of blank output.&lt;/p&gt;

&lt;p&gt;If you find a case where EDGAR behaves differently than described here, I'd&lt;br&gt;
genuinely like to know. That date-range trap only surfaced because I ran the&lt;br&gt;
example instead of just writing it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
