<?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>Google's News API has been gone since 2016, and people still search for it</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:38:27 +0000</pubDate>
      <link>https://dev.to/glitchbound/googles-news-api-has-been-gone-since-2016-and-people-still-search-for-it-2op9</link>
      <guid>https://dev.to/glitchbound/googles-news-api-has-been-gone-since-2016-and-people-still-search-for-it-2op9</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}

[google] It Takes More Than Two to Tango: Creating Effective Export Con...
         Just Security                                     2026-07-09
[google] China considers tighter export controls on AI models and chips
         Reuters                                           2026-07-20
[google] China's Localization Drive in Semiconductors Gains Impetus...
         CSIS | Center for Strategic and International...   2026-03-24
[google] China's Domestic DUV Push Puts ASML's Export-Control Advantage...
         EU Today                                          2026-07-28
&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>Find startups the day they raise, before the press release, with 30 lines of Python</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:37:15 +0000</pubDate>
      <link>https://dev.to/glitchbound/find-startups-the-day-they-raise-before-the-press-release-with-30-lines-of-python-2e52</link>
      <guid>https://dev.to/glitchbound/find-startups-the-day-they-raise-before-the-press-release-with-30-lines-of-python-2e52</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>
    <item>
      <title>The App Store top charts are a public API, and nobody archives them</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:34:54 +0000</pubDate>
      <link>https://dev.to/glitchbound/the-app-store-top-charts-are-a-public-api-and-nobody-archives-them-4m2h</link>
      <guid>https://dev.to/glitchbound/the-app-store-top-charts-are-a-public-api-and-nobody-archives-them-4m2h</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>WHOIS is gone, RDAP replaced it, and a 404 does not mean what you think</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:32:53 +0000</pubDate>
      <link>https://dev.to/glitchbound/whois-is-gone-rdap-replaced-it-and-a-404-does-not-mean-what-you-think-4cim</link>
      <guid>https://dev.to/glitchbound/whois-is-gone-rdap-replaced-it-and-a-404-does-not-mean-what-you-think-4cim</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>10,789 American employers publish their whole job list in one HTML page</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:31:35 +0000</pubDate>
      <link>https://dev.to/glitchbound/10789-american-employers-publish-their-whole-job-list-in-one-html-page-2db0</link>
      <guid>https://dev.to/glitchbound/10789-american-employers-publish-their-whole-job-list-in-one-html-page-2db0</guid>
      <description>&lt;p&gt;A Paylocity careers page looks like a React app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://recruiting.paylocity.com/recruiting/jobs/All/8f0bcd76-3ee7-42f2-b040-83afc6e6d564
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the network tab and there is no job API call to find. The jobs are&lt;br&gt;
already there, in the HTML, in one global:&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;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;UA&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;User-Agent&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;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;guid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8f0bcd76-3ee7-42f2-b040-83afc6e6d564&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://recruiting.paylocity.com/recruiting/jobs/All/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;guid&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&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;loads&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="nf"&gt;search&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;window\.pageData\s*=\s*(\{.*?\});\s*\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&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;S&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="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModuleTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# 'City of Columbia'
&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;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;          &lt;span class="c1"&gt;# 9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a job is not a card scraped off a page, it is a record:&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JobId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4514780&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JobTitle&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;Building and Grounds Maintenance Part Time&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;PublishedDate&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;2026-09-17T13:43:33-05:00&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;IsInternal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;IsRemote&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JobLocation&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;Address&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;1648 Trottwood Ave.&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;City&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;Columbia&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;State&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;TN&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;Zip&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;38401&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;Country&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;USA&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;A street address. Most career systems give you "Columbia, TN" if you are&lt;br&gt;
lucky and "Ridley Park - Parks and Recreation" if you are not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;recruiting.paylocity.com&lt;/code&gt; answers 404 to &lt;code&gt;/robots.txt&lt;/code&gt;, so there is nothing&lt;br&gt;
there to disallow anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Four things that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The assignment ends at &lt;code&gt;;\n&lt;/code&gt;.&lt;/strong&gt; Job descriptions contain semicolons. A&lt;br&gt;
greedy match, or a match that stops at the first &lt;code&gt;;&lt;/code&gt;, both give you a broken&lt;br&gt;
object. The newline is what makes it unambiguous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A retired board answers 200.&lt;/strong&gt; When an employer leaves Paylocity the guid&lt;br&gt;
keeps serving a portal page, just without &lt;code&gt;pageData&lt;/code&gt;. So "no pageData" is the&lt;br&gt;
proof a board is gone; the status code never says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;JobId&lt;/code&gt; is a global sequence, not a per-board one.&lt;/strong&gt; Measured over ten&lt;br&gt;
unrelated boards: the ranges interleave (a 40-person academy's ids sit inside&lt;br&gt;
a hospital group's range) and no id appeared at two employers. That matters&lt;br&gt;
because an employer can run several modules, and the same posting shows up on&lt;br&gt;
more than one. Key on the id alone and they collapse correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LocationName&lt;/code&gt; is not a location.&lt;/strong&gt; It is whatever the employer labelled the&lt;br&gt;
site: "Public Works", "Columbia Aquatics &amp;amp; Recreation Center", sometimes a&lt;br&gt;
person's name. The address object is the only place a city comes from.&lt;/p&gt;
&lt;h2&gt;
  
  
  How many there are
&lt;/h2&gt;

&lt;p&gt;A board is a guid, and nothing derives a guid from a company domain, so the&lt;br&gt;
list has to come from somewhere. The Wayback CDX index holds every URL it has&lt;br&gt;
archived on that host:&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;https://web.archive.org/cdx/search/cdx?url=recruiting.paylocity.com&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;&amp;amp;matchType=domain&amp;amp;collapse=urlkey&amp;amp;fl=original&amp;amp;output=json&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;&amp;amp;filter=original:.*&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;Rr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;ecruiting/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;Jj&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;obs/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;Aa&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;ll/.*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about that query cost me boards before they earned them. &lt;code&gt;limit&lt;/code&gt;&lt;br&gt;
stops at the first page and looks complete: the first sweep returned exactly&lt;br&gt;
20,000 URLs and 14,836 guids, which is a suspiciously round stopping point.&lt;br&gt;
And the filter is a case-sensitive regex, while the archive holds the path&lt;br&gt;
capitalised as well; fixing the case alone found 1,821 more.&lt;/p&gt;

&lt;p&gt;Read live on 2026-09-24: &lt;strong&gt;16,936 candidate boards, 13,581 that still answer,&lt;br&gt;
10,789 with open roles, 146,046 jobs.&lt;/strong&gt; One GET each, six at a time, a&lt;br&gt;
quarter of a second apart: 41 minutes for the lot, and not one request&lt;br&gt;
refused.&lt;/p&gt;

&lt;p&gt;The employers are the interesting part. This is not the Fortune 500, which&lt;br&gt;
every jobs dataset already has three times over. It is DOCS Health, a Taco&lt;br&gt;
Bell franchise group with 2,219 openings, Farmers Home Furniture, credit&lt;br&gt;
unions, dialysis clinics, car dealerships, county governments, country clubs.&lt;br&gt;
The half of the American labour market that never appears in an enterprise&lt;br&gt;
ATS index.&lt;/p&gt;




&lt;p&gt;If you want the rows rather than the code, that is what&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ats-jobs-scraper" rel="noopener noreferrer"&gt;ats-jobs-scraper&lt;/a&gt; does: a&lt;br&gt;
company name in, every open posting out, from Paylocity and twenty-one other&lt;br&gt;
career systems, as JSON.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>BrassRing has a JSON job search, and Home Depot publishes 23,982 jobs through it</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:28:03 +0000</pubDate>
      <link>https://dev.to/glitchbound/brassring-has-a-json-job-search-and-home-depot-publishes-23982-jobs-through-it-14in</link>
      <guid>https://dev.to/glitchbound/brassring-has-a-json-job-search-and-home-depot-publishes-23982-jobs-through-it-14in</guid>
      <description>&lt;p&gt;Best Buy's careers site is a ServiceNow portal. Its apply links go somewhere&lt;br&gt;
else: &lt;code&gt;sjobs.brassring.com&lt;/code&gt;, which is BrassRing (Infinite Talent, once IBM&lt;br&gt;
Kenexa). One host serves every employer on it, and a board is a partner id&lt;br&gt;
plus a site id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sjobs.brassring.com/TGnewUI/Search/Home/Home?partnerid=25632&amp;amp;siteid=5649
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one is Best Buy. Home Depot's store jobs are 25526/5032, Walgreens&lt;br&gt;
26336/5014. There is no robots.txt on the host (404), and the search the page&lt;br&gt;
itself calls is plain JSON.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two calls
&lt;/h2&gt;

&lt;p&gt;The search page carries an anti-forgery token, and the Ajax calls want it as a&lt;br&gt;
header named &lt;code&gt;RFT&lt;/code&gt;. Without it every call is HTTP 500 with an HTML error page,&lt;br&gt;
which is what makes this look closed when it is not.&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;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&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;https://sjobs.brassring.com/TGnewUI/Search/Home/Home&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;params&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;partnerid&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;25632&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;siteid&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;5649&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;User-Agent&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;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="n"&gt;text&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&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;name=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__RequestVerificationToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*value=&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="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;home&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="n"&gt;H&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;RFT&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Requested-With&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;XMLHttpRequest&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&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;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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://sjobs.brassring.com/TgNewUI/Search/Ajax/MatchedJobs&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="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&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;PartnerId&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;25632&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;SiteId&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;5649&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;Keyword&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Location&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeywordCustomSolrFields&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;JobTitle,Location&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;LocationCustomSolrFields&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;Location&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;FacetFilterFields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TurnOffHttps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Latitude&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Longitude&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PowerSearchOptions&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;PowerSearchOption&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;encryptedsessionvalue&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JobsCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                      &lt;span class="c1"&gt;# 2684
&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;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;Job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;               &lt;span class="c1"&gt;# 50
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job arrives as a list of question/value pairs rather than an object:&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="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QuestionName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Value&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;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;Job&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="c1"&gt;# {'reqid': '3761989', 'jobtitle': 'Retail Sales Associate',
#  'lastupdated': '22-Sep-2026', 'formtext12': 'Greensboro',
#  'formtext10': 'North Carolina ', 'formtext1': 'Part time ', ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pages come from a different endpoint, &lt;code&gt;ProcessSortAndShowMoreJobs&lt;/code&gt;, whose keys&lt;br&gt;
are lower-cased (&lt;code&gt;partnerId&lt;/code&gt;, &lt;code&gt;pageNumber&lt;/code&gt;). &lt;code&gt;PageNumber&lt;/code&gt; on &lt;code&gt;MatchedJobs&lt;/code&gt;&lt;br&gt;
looks like it pages and does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap: the sort repeats pages
&lt;/h2&gt;

&lt;p&gt;A site offers two sorts, date and title, and both are full of ties, so&lt;br&gt;
consecutive pages overlap and skip. Sweeping Best Buy's 54 pages once by date&lt;br&gt;
returned 2,299 distinct jobs of 2,680. Not a rate limit, not an error: just an&lt;br&gt;
unstable sort under a paging API.&lt;/p&gt;

&lt;p&gt;What works is sweeping more than once and joining on the requisition id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;date sweep                 2,299
title sweep                2,580
date + title + date        2,661   (99.3%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop as soon as the union reaches the declared count, and most sites finish in&lt;br&gt;
one sweep. Read that way, live on 2026-09-22: Best Buy 2,684 of 2,684 in 44&lt;br&gt;
seconds, Performance Food Group 1,359 of 1,359, Home Depot 23,876 of 23,982 in&lt;br&gt;
1,441 requests.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which fields hold the place changes per site
&lt;/h2&gt;

&lt;p&gt;The page config says what each site shows under a title:&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="nl"&gt;"JobFieldsToDisplay"&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="nl"&gt;"JobTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jobtitle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                       &lt;/span&gt;&lt;span class="nl"&gt;"Position3"&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;"formtext12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"formtext10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"formtext1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
                       &lt;/span&gt;&lt;span class="nl"&gt;"Summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jobdescription"&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;Best Buy shows city, state and schedule in custom fields. Home Depot has a&lt;br&gt;
&lt;code&gt;location&lt;/code&gt; field and uses it. Performance Food Group shows its division, then&lt;br&gt;
"St Louis, Missouri (MO)", then a facility name, and its &lt;code&gt;location&lt;/code&gt; field&lt;br&gt;
holds the facility. Edward Jones shows the requisition number first, and&lt;br&gt;
TeamHealth shows a medical specialty and no place at all.&lt;/p&gt;

&lt;p&gt;A rule that survives all of those: prefer a shown value that reads as "City,&lt;br&gt;
State"; else the site's own &lt;code&gt;location&lt;/code&gt;; else join the shown values up to the&lt;br&gt;
one naming a state, skipping ids and anything with a colon; and if none of&lt;br&gt;
them names a state, publish no location rather than a wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many employers are on it
&lt;/h2&gt;

&lt;p&gt;The Wayback CDX index lists archived search pages: 727 partner/site pairs, 489&lt;br&gt;
of which still answer, 120 with open roles. After dropping internal-only&lt;br&gt;
sites, a template site and a network that re-posts other employers' jobs, 98&lt;br&gt;
boards and 72,436 jobs, including Home Depot's stores (23,982), Walgreens&lt;br&gt;
(22,446), Best Buy, Harbor Freight, Infosys, Performance Food Group, Hobby&lt;br&gt;
Lobby, GardaWorld, ADM, Edward Jones, Publix, American Greetings, Bechtel,&lt;br&gt;
GUESS, AAFES, U.S. Steel and Stantec.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: an employer runs several sites over one partner&lt;br&gt;
id (ADM in six languages, Edward Jones three, Walgreens two), and the same&lt;br&gt;
requisition appears on more than one. The requisition is keyed per partner, so&lt;br&gt;
partner + reqid is the job.&lt;/p&gt;




&lt;p&gt;If you want the rows rather than the code, that is what&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/ats-jobs-scraper" rel="noopener noreferrer"&gt;ats-jobs-scraper&lt;/a&gt; does: a&lt;br&gt;
company name in, every open posting out, from BrassRing and twenty other&lt;br&gt;
career systems, as JSON.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Workday says the board has 2,000 jobs. Target has 12,525. Reading the rest.</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:25:25 +0000</pubDate>
      <link>https://dev.to/glitchbound/workday-says-the-board-has-2000-jobs-target-has-12525-reading-the-rest-7c9</link>
      <guid>https://dev.to/glitchbound/workday-says-the-board-has-2000-jobs-target-has-12525-reading-the-rest-7c9</guid>
      <description>&lt;p&gt;Ask a Workday career site for its jobs and the first answer states a total:&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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://target.wd5.myworkdayjobs.com/wday/cxs/target/targetcareers/jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&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;appliedFacets&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;searchText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;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="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;# 2000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Target does not have 2,000 open jobs. It has 12,525. The number is the most&lt;br&gt;
that search will return, and once you have paged to it the API just re-serves&lt;br&gt;
page one: offsets past the ceiling come back as the first twenty postings&lt;br&gt;
again, forever, which is how a paging loop that stops on "empty page" turns&lt;br&gt;
into an infinite one.&lt;/p&gt;

&lt;p&gt;Measured on 2026-09-22 across 3,916 Workday boards: 59 of them sit at exactly&lt;br&gt;
that wall (a board shows 1,990-2,000 because postings of one requisition merge&lt;br&gt;
into one job). Among them Target, Walmart, NVIDIA, Accenture, Citi, Airbus,&lt;br&gt;
T-Mobile, PNC, Abbott, Kohl's, Sysco, Circle K and Trinity Health.&lt;/p&gt;
&lt;h2&gt;
  
  
  The counts are in the same answer you already have
&lt;/h2&gt;

&lt;p&gt;The response carries a &lt;code&gt;facets&lt;/code&gt; list, and those counts are of the whole board,&lt;br&gt;
not of the 2,000:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Location_Country                 United States of America  12,452   India  73
Location_Region_State_Province   California 1,701   Texas 879   Florida 803 ...
jobFamilyGroup                   Stores 12,023   Internships 132 ...
workerSubType                    Regular 11,538   Seasonal 854   Intern 133
timeType                         Variable 11,427   Full time 1,098
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of those sum to exactly 12,525. That agreement is the useful part: a&lt;br&gt;
facet a posting carries once sums to the board, a facet it can carry several&lt;br&gt;
times (skills, locations) sums higher, and one only some postings carry sums&lt;br&gt;
lower. So the board's real size is the sum the most facets agree on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Each facet value is its own search, with its own ceiling
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;appliedFacets&lt;/code&gt; narrows the search, and the narrowed search gets a fresh&lt;br&gt;
2,000:&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;body&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;appliedFacets&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;Location_Region_State_Province&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;&amp;lt;value id&amp;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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;searchText&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;California is 1,701 postings, and 1,701 come back. So the board is read slice&lt;br&gt;
by slice and the slices are joined on each posting's &lt;code&gt;externalPath&lt;/code&gt;, which is&lt;br&gt;
the posting's own address and therefore the one key that cannot collide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the first page. If &lt;code&gt;total&lt;/code&gt; is exactly 2,000, take the facets.&lt;/li&gt;
&lt;li&gt;Work out the real size (the sum the most facets agree on).&lt;/li&gt;
&lt;li&gt;Pick the facet that covers that size and whose every value is under the
ceiling, fewest values first. Target: its 52 states.&lt;/li&gt;
&lt;li&gt;Where one value is still over the ceiling, apply it and split again by a
second facet. Petco needs this: 2,980 of its jobs are "Retail (Stores)",
and inside that slice the schedule splits it 1,893 / 1,024.&lt;/li&gt;
&lt;li&gt;Where no facet covers the slice at all, use the best partial one AND read
the slice's own 2,000: Petco's schedule field is blank on 63 postings, and
those come back in the plain read.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  What it returns
&lt;/h2&gt;

&lt;p&gt;Read whole, on 2026-09-22:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Target      12,499 of 12,536   655 requests
Michaels     5,071 of  5,071   279
Petco        3,774 of  3,798   298
Home Depot  (Workday corporate site, 1,031, no ceiling)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walmart went from 1,990 rows to 21,202, Circle K from 2,000 to 11,397.&lt;/p&gt;

&lt;p&gt;Two things worth keeping when you build this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed slice falls back to the plain 2,000.&lt;/strong&gt; Target is ~650 requests&lt;br&gt;
against ~100, so a transient error is six times likelier; a caller who got&lt;br&gt;
2,000 rows yesterday should not get an exception today for the sake of the&lt;br&gt;
other 10,499.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing splits under the ceiling.&lt;/strong&gt; If the caller wants 1,000 rows, the&lt;br&gt;
plain search already answers, and the split is pure cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing in that answer
&lt;/h2&gt;

&lt;p&gt;While measuring this I found that &lt;code&gt;bulletFields[0]&lt;/code&gt;, which most code (mine&lt;br&gt;
included) uses as the job id, is not an id on 224 of those boards: Thales&lt;br&gt;
prints "Regular Employee" there, Michaels the state, Cracker Barrel the&lt;br&gt;
store's street address. Anything that de-duplicates on it collapses the board:&lt;br&gt;
61,107 jobs became 16,314, and a run for Thales returned 7 rows for 200 jobs.&lt;br&gt;
The requisition is in the posting's address instead&lt;br&gt;
(&lt;code&gt;/job/&amp;lt;place&amp;gt;/&amp;lt;Title-Slug&amp;gt;_&amp;lt;req&amp;gt;&lt;/code&gt;), and the bullet is only safe when it IS&lt;br&gt;
that requisition.&lt;/p&gt;




&lt;p&gt;If you want the rows rather than the code, this is what&lt;br&gt;
&lt;a href="https://apify.com/glitchbound/workday-jobs-scraper" rel="noopener noreferrer"&gt;workday-jobs-scraper&lt;/a&gt;&lt;br&gt;
does: a company name in, every open posting out, past the ceiling, as JSON.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I bypassed my own Claude Code deny-list in eight ways. Only an allow-list held.</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 11 Sep 2026 15:47:38 +0000</pubDate>
      <link>https://dev.to/glitchbound/i-bypassed-my-own-claude-code-deny-list-in-eight-ways-only-an-allow-list-held-lp8</link>
      <guid>https://dev.to/glitchbound/i-bypassed-my-own-claude-code-deny-list-in-eight-ways-only-an-allow-list-held-lp8</guid>
      <description>&lt;p&gt;In April 2026 a coding agent at PocketOS hit a credential mismatch in staging, found an infrastructure token in an unrelated file, and deleted the production database and its volume backups in one API call. Thirty hours down. Every post-mortem named the same causes: a token within reach, no gate on destructive actions, backups in the same blast radius. Nobody blamed the model.&lt;/p&gt;

&lt;p&gt;Most teams I talk to protect against this with a line in CLAUDE.md that says "never touch production". That is a sign, not a lock. So I built the lock, then spent an evening trying to break it. Here is what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one: the deny-list
&lt;/h2&gt;

&lt;p&gt;Claude Code has PreToolUse hooks: a small program that receives the tool call as JSON on stdin and answers with an exit code (0 allow, 2 deny). The obvious first guard for a protected folder is a deny-list. Block &lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;mv&lt;/code&gt;, &lt;code&gt;rmdir&lt;/code&gt; when the command mentions &lt;code&gt;protected/&lt;/code&gt;. Ten lines. Done.&lt;/p&gt;

&lt;p&gt;It took minutes to defeat. All of these wrote into the protected tree untouched:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bypass&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Other verbs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;tee&lt;/code&gt;, &lt;code&gt;dd&lt;/code&gt;, &lt;code&gt;sed -i&lt;/code&gt;, &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;rsync&lt;/code&gt; into &lt;code&gt;protected/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested interpreter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sh -c "rm protected/x"&lt;/code&gt;, &lt;code&gt;perl -e 'unlink "protected/x"'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable indirection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;P=protected; rm $P/x&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subshell&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(cd protected &amp;amp;&amp;amp; rm x)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acting flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;find protected -delete&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline&lt;/td&gt;
&lt;td&gt;`ls protected \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clobber operator&lt;/td&gt;
&lt;td&gt;{% raw %}`echo x &amp;gt;\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symlink&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;ln -s protected alias; rm alias/x&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Worse: two of the blocks the guard &lt;em&gt;did&lt;/em&gt; produce came from a quoting parse error, not from detection. A guard that is wrong in both directions cannot be reasoned about at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: invert the rule
&lt;/h2&gt;

&lt;p&gt;The fix is not a longer deny-list. It is a different rule: &lt;strong&gt;referencing a protected path is denied unless every verb in the command is on a short read-only allow-list&lt;/strong&gt; (&lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt; without acting flags, and a few more). Anything unknown fails closed. A tool nobody predicted is covered by construction, because nothing has to be predicted.&lt;/p&gt;

&lt;p&gt;Three details that only showed up under attack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resolve paths before comparing.&lt;/strong&gt; &lt;code&gt;../protected/x&lt;/code&gt;, symlinks and &lt;code&gt;cd&lt;/code&gt; in the middle of a command all change what a relative path means. The guard tracks the session &lt;code&gt;cwd&lt;/code&gt; that Claude Code sends and resolves every token with &lt;code&gt;realpath&lt;/code&gt; before deciding. Using the hook process's own cwd made the same command allowed or blocked depending on where the hook happened to be launched from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every path key.&lt;/strong&gt; The write guard originally read only &lt;code&gt;tool_input.file_path&lt;/code&gt;. &lt;code&gt;NotebookEdit&lt;/code&gt; sends &lt;code&gt;notebook_path&lt;/code&gt;; &lt;code&gt;MultiEdit&lt;/code&gt; nests paths under &lt;code&gt;edits[]&lt;/code&gt;. Adding those tools to the matcher looked like coverage while the guard inspected nothing. A guard that is wired but cannot read its input is more dangerous than a missing one, because the settings file now lies to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed on confusion.&lt;/strong&gt; Unparseable quoting, a missing config file, a malformed policy, a payload with no path: every one of these denies. A guard that waves something through when confused is worse than no guard.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Round three: destructive commands that touch no path
&lt;/h2&gt;

&lt;p&gt;The path rule says nothing about &lt;code&gt;git push --force&lt;/code&gt;, &lt;code&gt;git reset --hard&lt;/code&gt;, &lt;code&gt;curl | sh&lt;/code&gt;, &lt;code&gt;terraform destroy&lt;/code&gt; or &lt;code&gt;DROP TABLE&lt;/code&gt;. Those get a second layer: regex deny patterns checked on the raw command string, before any path logic, so quoting tricks around the path parser cannot route around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it or it does not exist
&lt;/h2&gt;

&lt;p&gt;Every bypass above is now a permanent regression test. The suite feeds each hook the JSON Claude Code would send and asserts the exit code, 125 assertions, and it snapshots and restores everything it mutates so a Ctrl-C mid-run cannot leave the repo in a broken state (the suite even kills a nested copy of itself at the dangerous moment to prove the trap works).&lt;/p&gt;

&lt;p&gt;Every block also writes one JSON line to an audit log and, if you set a Slack or Discord webhook, posts the block to the team channel in the same call. A small script turns a month of that log into a report: how many agent actions were blocked, by reason, by tool, by day. A control nobody can see is a control nobody funds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript: the built-in rules had bypasses too
&lt;/h2&gt;

&lt;p&gt;The Claude Code 2.1.268 changelog fixed deny and ask rules that did not apply on symlinked directories, and a case where a Read or Edit deny rule did not apply when &lt;code&gt;env -C&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt; or a similar command the permission checker cannot analyze was on the same line. That is not a criticism of the permission system; it is the reason to put a tested hook layer in front of it and to re-run the tests on every release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take it
&lt;/h2&gt;

&lt;p&gt;The kit is public: &lt;a href="https://github.com/danielhagever/agent-guardrails-kit" rel="noopener noreferrer"&gt;agent-guardrails-kit&lt;/a&gt;. Copy &lt;code&gt;hooks/&lt;/code&gt;, &lt;code&gt;gates/&lt;/code&gt; and &lt;code&gt;config.json&lt;/code&gt; into a repo, edit &lt;code&gt;protected_paths&lt;/code&gt; and &lt;code&gt;deny_patterns&lt;/code&gt;, wire two hooks in &lt;code&gt;.claude/settings.json&lt;/code&gt;, run the tests.&lt;/p&gt;

&lt;p&gt;If you want to know where your current setup stands before touching anything, there is a grader that reads your &lt;code&gt;settings.json&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; in the browser and scores it out of 10: &lt;a href="https://agent-guardrails.meshulam791.workers.dev/grade" rel="noopener noreferrer"&gt;agent-guardrails.meshulam791.workers.dev/grade&lt;/a&gt;. Nothing is uploaded. Here is what the full &lt;a href="https://agent-guardrails.meshulam791.workers.dev/sample-report" rel="noopener noreferrer"&gt;exposure report&lt;/a&gt; looks like on a fictional repo.&lt;/p&gt;

&lt;p&gt;I install and test-prove this for teams on Claude Code and Cursor. Details on the same site.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>security</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <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;53,000 company boards, 23 ATS platforms, 2,442,000 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 53,000 company boards on 23 ATS platforms,&lt;br&gt;
and the index currently holds 2,442,000 open roles and &lt;strong&gt;874,000 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: 53,000 company boards across 23 ATS&lt;br&gt;
platforms, 2,442,000 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>
  </channel>
</rss>
