<?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 Play top charts API: Top Free, Top Grossing and Trending apps in 130 countries</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:01:07 +0000</pubDate>
      <link>https://dev.to/glitchbound/google-play-top-charts-api-top-free-top-grossing-and-trending-apps-in-130-countries-31ac</link>
      <guid>https://dev.to/glitchbound/google-play-top-charts-api-top-free-top-grossing-and-trending-apps-in-130-countries-31ac</guid>
      <description>&lt;p&gt;Google Play has no public API for its top charts. SerpApi and a few data vendors resell them; the Play Store web app itself reads them from an internal RPC, and that is what everyone else ends up reading too. It is fast (a 100-app chart in well under a second) and it has one behaviour worth knowing before you trust a single row of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The charts there are
&lt;/h2&gt;

&lt;p&gt;Five lists answer for any country and category:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chart&lt;/th&gt;
&lt;th&gt;What it ranks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top Free&lt;/td&gt;
&lt;td&gt;downloads, free apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top Paid&lt;/td&gt;
&lt;td&gt;downloads, paid apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top Grossing&lt;/td&gt;
&lt;td&gt;revenue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trending&lt;/td&gt;
&lt;td&gt;Google's "movers and shakers": what is rising fastest right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top New Free&lt;/td&gt;
&lt;td&gt;recently released free apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each goes up to 200 apps (Top New Free runs shorter in small markets). Trending is the one most people miss, and it is not a reshuffle of Top Free: of the top 100 in the US on 2026-10-18 the two shared 7 apps. Top New Free shared 1. If you are looking for breakouts, those two lists are where they show up first.&lt;/p&gt;

&lt;h2&gt;
  
  
  An unknown country is not an error
&lt;/h2&gt;

&lt;p&gt;Ask for a country code Google does not serve and you do not get a 400. You get a full, plausible chart, from somewhere else. Which somewhere was the surprise: the answer for an unknown code is the chart of the country &lt;strong&gt;the request comes from&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I found this the embarrassing way. My scraper only offers countries I have checked, and the check compared each country's chart with the answer for &lt;code&gt;zz&lt;/code&gt;. I ran it in September from Israel; Israel's chart matched &lt;code&gt;zz&lt;/code&gt;, so Israel went on the excluded list as "not a real market". Run again in October from the US, &lt;code&gt;zz&lt;/code&gt; matched the US chart exactly, and Israel came back with a chart of its own: Rav-Kav Online, Moovit, Wolt and Hebrew-named apps in the top 40. The check had excluded Israel for being Israel.&lt;/p&gt;

&lt;p&gt;The reference that does not move with you is the chart Google serves for a code with no Play market at all. Antarctica (&lt;code&gt;aq&lt;/code&gt;), Bouvet Island, Heard Island and the other uninhabited territories all get the same list, and a country is real when its chart differs from that one and from every other country's. By that test 130 codes are real markets today. Five that look like they should be are not: Ethiopia, Montenegro, Afghanistan, Brunei and Barbados answer with the no-market chart. Russia answers Top Free only, since Google Play takes no payments there.&lt;/p&gt;

&lt;p&gt;If you pull Play charts for many countries, run that test yourself: a wrong market fails silently, and you will be the one paying for its rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading them without writing the RPC
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/glitchbound/google-play-charts-scraper" rel="noopener noreferrer"&gt;Google Play Top Charts Scraper&lt;/a&gt; on Apify, which reads all five charts for the 130 verified countries and 54 categories, refuses a country it has not verified rather than returning someone else's chart, and asks Google twice before calling a chart empty (Google sometimes answers a real chart with nothing, and a minute later in full).&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;"countries"&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;"us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"il"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"br"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"charts"&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;"top-grossing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trending"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"categories"&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;"all-games"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row carries rank, app id, title, developer, rating, install band and price. &lt;code&gt;includeDetails&lt;/code&gt; adds exact installs, rating counts, release and update dates and the developer's contact email. For the iPhone side, the &lt;a href="https://apify.com/glitchbound/app-charts-scraper" rel="noopener noreferrer"&gt;App Store sibling&lt;/a&gt; returns the same row shape, so the two join on title and developer.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>android</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Company career page scraper: every open job from any company, by name or domain</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:59:24 +0000</pubDate>
      <link>https://dev.to/glitchbound/company-career-page-scraper-every-open-job-from-any-company-by-name-or-domain-ede</link>
      <guid>https://dev.to/glitchbound/company-career-page-scraper-every-open-job-from-any-company-by-name-or-domain-ede</guid>
      <description>&lt;p&gt;Nearly every company's careers page is a skin over one of a dozen applicant tracking systems: Workday, Greenhouse, Lever, Ashby, Oracle, SAP SuccessFactors, iCIMS, Eightfold, Phenom and a few more. Each of them publishes the open jobs as JSON, because job boards and Google need to index them. Scraping a career page is mostly two problems: knowing which system and which board a company uses, and reading that board completely. Both have traps that return plausible, wrong data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the name on the board is not always the company
&lt;/h2&gt;

&lt;p&gt;Guessing a board from a company name ("stripe" on Greenhouse) works for startups. For big brands it goes wrong in ways that look right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Greenhouse board that names itself &lt;strong&gt;"LinkedIn"&lt;/strong&gt; belongs to Obsidian Security; it is the feed they post to LinkedIn. Ask a naive scraper for LinkedIn's jobs and you get Obsidian's account executives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Meta"&lt;/strong&gt; on Recruitee is a one-job account of a much smaller company. &lt;strong&gt;"Honda"&lt;/strong&gt; on Paylocity is a dealership in Akron with two openings. American Honda's real site is careers.honda.com, which runs on Phenom.&lt;/li&gt;
&lt;li&gt;Google, Apple, IBM, Meta, LinkedIn and Tesla run careers software of their own. The honest answer for those is "not on a system I can read", not the nearest board with the right word in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What works is a directory built from boards that have actually answered, with one answer per name, plus a list of brands whose name must never be matched to somebody else's board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the same job, twice
&lt;/h2&gt;

&lt;p&gt;Citi and Netflix put Eightfold, a careers front end, over their Workday. A name finds both, and a scraper that reads both returns every job twice: Citi's "Young Apprentice - C00 - MUMBAI" came back from each. The front end serves the underlying system's requisition id, so (company, job id) identifies the job on either side. Keep one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: filtering after you read misses most matches
&lt;/h2&gt;

&lt;p&gt;Big employers have thousands of open roles, and most scrapers read the newest N and filter by title afterwards. Measured against the newest 1,000 (300 for EY):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Found by reading, then filtering&lt;/th&gt;
&lt;th&gt;Found by the site's own search&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EY (SAP)&lt;/td&gt;
&lt;td&gt;senior consultant&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;690&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVIDIA (Workday)&lt;/td&gt;
&lt;td&gt;software engineer&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;442&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft (Eightfold)&lt;/td&gt;
&lt;td&gt;software engineer&lt;/td&gt;
&lt;td&gt;177&lt;/td&gt;
&lt;td&gt;415&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon&lt;/td&gt;
&lt;td&gt;product manager&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;492&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these systems has a keyword search in the same public feed: Workday's &lt;code&gt;searchText&lt;/code&gt;, Amazon's &lt;code&gt;base_query&lt;/code&gt;, Eightfold's &lt;code&gt;query&lt;/code&gt;, SAP's &lt;code&gt;q&lt;/code&gt;. Send the title there, keep the rows whose title actually holds it, and stop paging once a few pages in a row hold no match. The searches rank title matches first, so that rule kept 3,264 of 3,264 matches on Workday while reading 239 of the 502 pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or skip all of it
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/glitchbound/ats-jobs-scraper" rel="noopener noreferrer"&gt;Company Career Page Jobs Scraper&lt;/a&gt; on Apify, which does the above for 78,000 company names across 24 systems: type &lt;code&gt;Stripe&lt;/code&gt;, &lt;code&gt;stripe.com&lt;/code&gt; or &lt;code&gt;Home Depot&lt;/code&gt;, add a title or a city, and get the employer's own postings. Brands that publish on their own software are named as such instead of guessed, a job seen through two systems is charged once, and filtered-out rows are not charged.&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;"companies"&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;"EY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NVIDIA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Amazon"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"titleKeywords"&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;"software engineer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"senior consultant"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"locationKeywords"&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;"London"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is also callable from an AI agent through the Apify MCP server.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webscraping</category>
      <category>career</category>
    </item>
    <item>
      <title>Workday Jobs Scraper: every posting from any Workday careers site, by company name</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:59:05 +0000</pubDate>
      <link>https://dev.to/glitchbound/workday-jobs-scraper-every-posting-from-any-workday-careers-site-by-company-name-8bd</link>
      <guid>https://dev.to/glitchbound/workday-jobs-scraper-every-posting-from-any-workday-careers-site-by-company-name-8bd</guid>
      <description>&lt;p&gt;Large employers post their jobs on Workday: NVIDIA, Salesforce, Walmart, Target, Wells Fargo, Pfizer, Boeing. Every one of those &lt;code&gt;*.myworkdayjobs.com&lt;/code&gt; career sites looks different in the browser and is the same API underneath:&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;requests&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;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://nvidia.wd5.myworkdayjobs.com/wday/cxs/nvidia/NVIDIAExternalCareerSite/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;data&lt;/span&gt; &lt;span class="o"&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="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;total&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;j&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&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;jobPostings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no login, no cookies: it is the endpoint the careers page itself calls. A scraper is a loop over &lt;code&gt;offset&lt;/code&gt;. Three things turn that loop into a wrong answer, and none of them raises an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You need the URL, and a company name does not give it to you
&lt;/h2&gt;

&lt;p&gt;The address carries three facts a name cannot derive: the host label (&lt;code&gt;nvidia&lt;/code&gt;), the data centre (&lt;code&gt;wd5&lt;/code&gt;; companies sit anywhere from &lt;code&gt;wd1&lt;/code&gt; to &lt;code&gt;wd504&lt;/code&gt; and move between them) and the career site's own name (&lt;code&gt;NVIDIAExternalCareerSite&lt;/code&gt;). Guessing across all three mostly produces 404s and 422s.&lt;/p&gt;

&lt;p&gt;What works is a map built from boards that have actually answered with real postings. The one this post's scraper ships holds 3,056 employers, so &lt;code&gt;NVIDIA&lt;/code&gt;, &lt;code&gt;Wells Fargo&lt;/code&gt; or &lt;code&gt;PwC&lt;/code&gt; resolve without anyone hunting for a URL. A name it does not know is better refused than guessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;limit&lt;/code&gt; is 20, and &lt;code&gt;total&lt;/code&gt; is a ceiling
&lt;/h2&gt;

&lt;p&gt;Ask for 50 a page and the answer is an error. Page past 2,000 and big boards just serve page one again, so a loop that stops on an empty page never stops. On those boards &lt;code&gt;total&lt;/code&gt; says 2,000 whatever the real count is. I wrote up the ceiling and the way past it (the facet counts in the same answer) &lt;a href="https://dev.to/glitchbound/workday-says-the-board-has-2000-jobs-target-has-12525-reading-the-rest-7c9"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Filtering after you read misses most of the matches
&lt;/h2&gt;

&lt;p&gt;This is the one that costs the most, because the output looks fine.&lt;/p&gt;

&lt;p&gt;Most scrapers read the newest N postings (often 1,000), then keep the ones whose title matches. On a small board that is everything. On a big one it is a sample. Measured on 2026-10-18 with "software engineer":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Matches in the newest 1,000&lt;/th&gt;
&lt;th&gt;Matches on the board&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NVIDIA&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;442&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salesforce, "account executive"&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;448&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastercard&lt;/td&gt;
&lt;td&gt;191&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is already in the request: &lt;code&gt;searchText&lt;/code&gt;. Workday searches title and description, so the answer is a superset, and you keep the rows whose title holds the words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&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;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="n"&gt;url&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="n"&gt;offset&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="n"&gt;text&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;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;jobPostings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="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="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
        &lt;span class="c1"&gt;# Workday ranks title matches first: once a page past the second has
&lt;/span&gt;        &lt;span class="c1"&gt;# none, the rest are postings that only mention the words.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hits&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;out&lt;/span&gt;
        &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On eight title and board pairs that kept every one of 3,264 title matches and read 239 of the 502 pages the searches declared. The same goes for a place: &lt;code&gt;searchText="Israel"&lt;/code&gt; at NVIDIA finds 233 jobs, where the newest 1,000 held 61.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or skip all of it
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://apify.com/glitchbound/workday-jobs-scraper" rel="noopener noreferrer"&gt;Workday Jobs Scraper&lt;/a&gt; on Apify, which does the three things above: a company name for 3,056 employers, the 2,000 ceiling, and titles and places sent to Workday's own search. A company that is not on Workday is looked up where it does post (Oracle, Eightfold, Phenom, SAP and more), and filtered-out rows are not charged.&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;"companies"&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;"NVIDIA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wells Fargo"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"titleKeywords"&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;"software engineer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"locationKeywords"&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;"Israel"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is also callable from an AI agent through the Apify MCP server, so "find software engineer openings at NVIDIA in Israel" is one tool call.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>webscraping</category>
      <category>career</category>
    </item>
    <item>
      <title>Google's News API has been gone since 2016, and people still search for it</title>
      <dc:creator>Daniel Meshulam</dc:creator>
      <pubDate>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>
  </channel>
</rss>
