<?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: Devil Scrapes</title>
    <description>The latest articles on DEV Community by Devil Scrapes (@devil_scrapes).</description>
    <link>https://dev.to/devil_scrapes</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%2F3960872%2Ffa930ad0-5ebc-4ca7-b894-6bc6fb3e2b40.png</url>
      <title>DEV Community: Devil Scrapes</title>
      <link>https://dev.to/devil_scrapes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devil_scrapes"/>
    <language>en</language>
    <item>
      <title>A fake 404 and a 403 that isn't a ban — the UK food ratings API</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Thu, 20 Aug 2026 04:12:06 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/a-fake-404-and-a-403-that-isnt-a-ban-the-uk-food-ratings-api-6f3</link>
      <guid>https://dev.to/devil_scrapes/a-fake-404-and-a-403-that-isnt-a-ban-the-uk-food-ratings-api-6f3</guid>
      <description>&lt;p&gt;We probed the UK Food Standards Agency's ratings API and got a 404 that said the API doesn't exist. It exists. Then we fixed that and got a 403 that looked exactly like an IP ban. It wasn't. Both responses are the API working as designed — and both designs are invisible until you've lost an afternoon to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Building on &lt;code&gt;api.ratings.food.gov.uk&lt;/code&gt;? Two headers-and-habits rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send &lt;code&gt;x-api-version: 2&lt;/code&gt; on every request.&lt;/strong&gt; Without it the API answers &lt;code&gt;404 "The API 'Establishments' doesn't exist"&lt;/code&gt; — a message engineered to make you re-check your URL forever. The URL is fine; the header is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never query &lt;code&gt;/Establishments&lt;/code&gt; bare.&lt;/strong&gt; An unfiltered query returns &lt;code&gt;403 "This is a CPU intensive query: please use one of the documented filters"&lt;/code&gt;. That's not a ban and not rate limiting — it's a query-cost guard, and it means a page-through-everything scraper design is dead on arrival. Filter by local authority, name or address on every request.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 404 that means "missing header"
&lt;/h2&gt;

&lt;p&gt;Here's the trap in its natural habitat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.ratings.food.gov.uk/Establishments?name=pizza
→ 404 {"Message": "The API 'Establishments' doesn't exist"}

GET https://api.ratings.food.gov.uk/Establishments?name=pizza
    x-api-version: 2
→ 200, establishments with ratings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same URL, same method, opposite outcome. A 404 with a body claiming the API doesn't exist reads like a retired endpoint — we initially logged it as one. Every dead-endpoint conclusion about this API that doesn't mention the version header is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the FSA API return 403 on a valid query?
&lt;/h2&gt;

&lt;p&gt;Because the query is valid &lt;em&gt;and expensive&lt;/em&gt;. The bare &lt;code&gt;Establishments&lt;/code&gt; listing would walk every food business in the UK, so the API refuses with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 {"Message": "This is a CPU intensive query: please use one of
     the documented filters in your query (e.g. filter by LocalAuthority)."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode this creates for scrapers is nasty: a 403 pattern-matches to "we've been blocked", which sends you down the proxy-rotation rabbit hole. No proxy fixes this, because nothing is blocked — the request shape is the problem. Narrow the query (local authority ID, business name, address, rating value) and the same client on the same IP gets a 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  What data is actually in there?
&lt;/h2&gt;

&lt;p&gt;Every food business establishment in England, Wales and Northern Ireland (plus Scotland's parallel scheme): business name and type, full address, the hygiene rating (0–5, or Scotland's pass/fail), the three inspection sub-scores (hygiene, structural, confidence in management), the rating date and the local authority. It's the dataset behind the green sticker in every UK café window — and it's genuinely useful for hospitality lead-gen, franchise diligence and food-safety monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/uk-food-hygiene-ratings-scraper" rel="noopener noreferrer"&gt;UK Food Hygiene Ratings Scraper&lt;/a&gt; queries the FSA ratings register with the version header, the mandatory filters and paging handled for you, and returns one flat row per establishment — name, type, address, rating, sub-scores, inspection date, authority — as JSON, CSV or Excel. Pay-per-result at &lt;strong&gt;$2.05 per 1,000 establishments&lt;/strong&gt;; an empty match costs only the start fee.&lt;/p&gt;

&lt;p&gt;We're not going to call this API easy: it hides behind a lying 404, refuses honest queries with a scary 403, and paginates region by region. Absorbing that is the product. 😈&lt;/p&gt;

&lt;p&gt;For neighbouring lead-gen sources we also run &lt;a href="https://apify.com/DevilScrapes/gleif-lei-records-scraper" rel="noopener noreferrer"&gt;UK company data via GLEIF LEI records&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/brreg-norway-companies-scraper" rel="noopener noreferrer"&gt;Norway's company register&lt;/a&gt; and &lt;a href="https://apify.com/DevilScrapes/bbb-business-leads-scraper" rel="noopener noreferrer"&gt;BBB business leads&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;On the FSA ratings API: &lt;code&gt;x-api-version: 2&lt;/code&gt; or every response is a fake 404, and always send a filter or the 403 you get isn't the ban it impersonates.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The API where page 21 is a 400 and 'no results' is a 404</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Thu, 20 Aug 2026 04:11:35 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/the-api-where-page-21-is-a-400-and-no-results-is-a-404-82a</link>
      <guid>https://dev.to/devil_scrapes/the-api-where-page-21-is-a-400-and-no-results-is-a-404-82a</guid>
      <description>&lt;p&gt;The Federal Register API has a results window that ends at document 2,000 — and the error you get when you cross it is an HTTP 400 that looks like &lt;em&gt;your&lt;/em&gt; bug. It also answers a perfectly healthy "no matches" query with a 404, which most HTTP clients treat as "the endpoint is gone."&lt;/p&gt;

&lt;p&gt;Both behaviours are real, both are undocumented where you'll actually look, and both will pass every test you write against a happy-path fixture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Building on &lt;code&gt;www.federalregister.gov/api/v1/documents.json&lt;/code&gt;? Three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You cannot page past 2,000 results.&lt;/strong&gt; The API returns HTTP 400 once &lt;code&gt;page × per_page&lt;/code&gt; crosses 2,000. Partition your query by date range and page inside each slice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 404 means "no documents matched", not "dead endpoint".&lt;/strong&gt; Treat it as an empty result set, not an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;per_page&lt;/code&gt; maxes at 100.&lt;/strong&gt; Anything higher is clamped or rejected, so a 10,000-document sweep is at minimum 100 requests before you even hit the window ceiling.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 2,000-document window
&lt;/h2&gt;

&lt;p&gt;A search for a common regulatory term matches tens of thousands of documents — we measured 10,000+ hits on a single term during our probe. The API happily reports that total, then refuses to show you most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page=20, per_page=100  → HTTP 200   (document 1,901–2,000)
page=21, per_page=100  → HTTP 400   (document 2,001+ — the window is shut)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is the same one every deep-paging API forces eventually: &lt;strong&gt;partition by &lt;code&gt;publication_date&lt;/code&gt;&lt;/strong&gt;, run each slice under the 2,000-document ceiling, and stitch. A scraper that silently stops at 2,000 and exits green is lying to its user — ours logs the ceiling and narrows the date slices automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the Federal Register API return 404 on a valid query?
&lt;/h2&gt;

&lt;p&gt;Because that's its way of saying "zero documents matched". A conditions query with no hits doesn't return &lt;code&gt;{"count": 0, "results": []}&lt;/code&gt; — it returns &lt;strong&gt;HTTP 404&lt;/strong&gt;. If your client raises on any 4xx (the default in most libraries), your pipeline dies on the first quiet news day, and the stack trace points at your URL builder, not at the API's convention.&lt;/p&gt;

&lt;p&gt;The correct behaviour is exactly what our client does: catch the 404, emit zero rows, exit clean. An empty result set is an answer, not a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the Federal Register API free?
&lt;/h2&gt;

&lt;p&gt;Yes — fully keyless, no registration, no OAuth. Which is why the whole difficulty of the target is the two conventions above plus schema discipline: documents omit fields freely (agencies, abstracts, page counts come and go per document type), so every optional field in your output schema needs to be nullable or your dataset writer dies mid-run on the first sparse record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/federal-register-documents-scraper" rel="noopener noreferrer"&gt;Federal Register Scraper&lt;/a&gt; searches the Federal Register — proposed rules, final rules, notices, presidential documents — by term, agency, document type and date range, and returns one flat row per document: document number, title, type, abstract, agencies, publication date, comment deadline and the canonical HTML/PDF URLs, as JSON, CSV or Excel. Pay-per-result at &lt;strong&gt;$2.05 per 1,000 documents&lt;/strong&gt;; a query that matches nothing costs you only the start fee.&lt;/p&gt;

&lt;p&gt;The paging window, the 404-means-empty convention, the sparse-field schema — absorbing those is our job, not yours. 😈&lt;/p&gt;

&lt;p&gt;If you track the regulatory pipeline end-to-end, we also run &lt;a href="https://apify.com/DevilScrapes/fda-recalls-scraper" rel="noopener noreferrer"&gt;FDA recalls&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/fda-510k-clearances-scraper" rel="noopener noreferrer"&gt;FDA 510(k) clearances&lt;/a&gt; and &lt;a href="https://apify.com/DevilScrapes/sec-xbrl-financials-scraper" rel="noopener noreferrer"&gt;SEC XBRL financials&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;On the Federal Register API: 404 means "no matches", 400 past document 2,000 means "partition by date", and every optional field is optional. Three rules, whole integration.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
      <category>opendata</category>
    </item>
    <item>
      <title>The API where 200 OK means your request was invalid</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:35:02 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/the-api-where-200-ok-means-your-request-was-invalid-22ok</link>
      <guid>https://dev.to/devil_scrapes/the-api-where-200-ok-means-your-request-was-invalid-22ok</guid>
      <description>&lt;p&gt;The World Bank's v2 API is free, keyless, and serves 20,000+ development indicators for every country on earth. It is also the only API I have met this year where a &lt;em&gt;successful&lt;/em&gt; response is not a JSON object, and where asking for an indicator that does not exist returns HTTP 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;World Bank v2 returns a &lt;strong&gt;two-element array&lt;/strong&gt;, not an object: &lt;code&gt;[{pagination meta}, [...rows]]&lt;/code&gt;. When your indicator code is wrong it returns &lt;strong&gt;HTTP 200 with a one-element array&lt;/strong&gt; carrying a &lt;code&gt;message&lt;/code&gt; block instead of rows. So the only safe way to consume it is to branch on array length before you look at anything else — status code and &lt;code&gt;try: rows = data["data"]&lt;/code&gt; will both lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wire format
&lt;/h2&gt;

&lt;p&gt;Here is a healthy response, trimmed:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;66&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;66&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lastupdated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-13"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"indicator"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NY.GDP.MKTP.CD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GDP (current US$)"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"country"&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;"id"&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="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brazil"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"countryiso3code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BRA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2279920092492.13&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Element 0 is pagination. Element 1 is the rows. There is no envelope key, no &lt;code&gt;data&lt;/code&gt;, no &lt;code&gt;results&lt;/code&gt;. Anyone who has written a client against a modern REST API will reach for &lt;code&gt;response.json()["data"]&lt;/code&gt; and get a &lt;code&gt;TypeError&lt;/code&gt; on a &lt;em&gt;working&lt;/em&gt; request.&lt;/p&gt;

&lt;p&gt;That part is merely annoying. Here is the part that costs you a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does an invalid indicator return 200?
&lt;/h2&gt;

&lt;p&gt;Ask for an indicator code that does not exist and the API answers:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The provided parameter value is not valid"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;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;Status: &lt;strong&gt;200 OK&lt;/strong&gt;. One element, not two. No exception, no error field your HTTP layer will notice.&lt;/p&gt;

&lt;p&gt;Chain that with the naive parse and the outcome is a run that completes successfully and emits zero rows. On a pay-per-result Actor that is the worst possible failure: the customer is charged a start fee, sees a green run, gets an empty dataset, and has no idea whether their query was wrong or our scraper was.&lt;/p&gt;

&lt;p&gt;So the shape check happens first, in one place, before pagination or retries get a say:&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;INVALID_INDICATOR_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&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;_parse_envelope&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected World Bank response shape&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&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="ow"&gt;or&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;INVALID_INDICATOR_KEY&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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;World Bank rejected the request: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;INVALID_INDICATOR_KEY&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;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isolating it means the paging loop never has to guess. Two shapes exist; exactly one function knows about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sparse by design: &lt;code&gt;value&lt;/code&gt; is legitimately null
&lt;/h2&gt;

&lt;p&gt;Development data has holes — a country did not report, a series starts in 1990, a war interrupted collection. &lt;code&gt;value: null&lt;/code&gt; is the normal case, not corruption.&lt;/p&gt;

&lt;p&gt;This matters for Apify Actors specifically, because a dataset schema that declares&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;will pass a QA run on Germany's GDP and then die on the first missing year in a real query. The fix is one character:&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We now treat "does this field survive a sparse record?" as a schema review question on every Actor, because the failure only ever reproduces on customer data.&lt;/p&gt;

&lt;h2&gt;
  
  
  One row per country, indicator and year
&lt;/h2&gt;

&lt;p&gt;The other design choice worth stating: the API nests &lt;code&gt;indicator&lt;/code&gt; and &lt;code&gt;country&lt;/code&gt; as objects, and a request can span many countries and years at once. Consumers almost always want a flat table.&lt;/p&gt;

&lt;p&gt;So the output is fully flattened — &lt;code&gt;country_name&lt;/code&gt;, &lt;code&gt;country_id&lt;/code&gt;, &lt;code&gt;indicator_id&lt;/code&gt;, &lt;code&gt;indicator_name&lt;/code&gt;, &lt;code&gt;year&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt; — one row per (country, indicator, year). That drops straight into a spreadsheet, a pandas frame, or a BI tool with no unnesting step, and it makes the billing unit legible: one row is one data point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is scraping the World Bank API legal?
&lt;/h2&gt;

&lt;p&gt;The World Bank publishes this data under an open licence and documents the API for public reuse — it is about as unambiguous as open data gets. Standard care still applies: request only what you need, and cite the source when you redistribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt;&lt;br&gt;
No. It is keyless and unauthenticated, and there is no bot detection to work around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;per_page&lt;/code&gt; max out at?&lt;/strong&gt;&lt;br&gt;
We page at 1000, which the API accepts comfortably. Watch &lt;code&gt;pages&lt;/code&gt; in element 0 of the envelope rather than guessing when to stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I find an indicator code?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;GET /v2/indicator/{code}&lt;/code&gt; is the catalogue endpoint — it also confirms an indicator exists before you page its data, which is the cheapest way to fail fast on a typo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my dataset smaller than countries × years?&lt;/strong&gt;&lt;br&gt;
Because the missing combinations were never reported. Absence of a row is real information about the series, not a scraper bug.&lt;/p&gt;




&lt;p&gt;Packaged and ready to run: &lt;strong&gt;&lt;a href="https://apify.com/DevilScrapes/world-bank-indicators-scraper" rel="noopener noreferrer"&gt;World Bank Indicators Scraper&lt;/a&gt;&lt;/strong&gt; — pick any indicators (GDP, population, CO2, poverty, and 20,000+ more), any countries, any year range, get one flat row per country/indicator/year as JSON, CSV, or Excel.&lt;/p&gt;

&lt;p&gt;We do the dirty work so your dataset stays clean. 😈&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>datascience</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>A 404 from SEC's XBRL API is usually the correct answer</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:34:24 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/a-404-from-secs-xbrl-api-is-usually-the-correct-answer-58lm</link>
      <guid>https://dev.to/devil_scrapes/a-404-from-secs-xbrl-api-is-usually-the-correct-answer-58lm</guid>
      <description>&lt;p&gt;SEC's XBRL API is the best-kept secret in financial data. No key, no scraping of HTML filings, no vendor contract: structured, comparable, machine-readable financial facts for every company that files with the SEC, straight from the source.&lt;/p&gt;

&lt;p&gt;Then you ask two companies for the same concept and one of them 404s. Nothing is broken. That 404 is the most important thing to understand about the whole API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A 404 from SEC's XBRL API usually means "this filer did not report this concept", not "your request was wrong."&lt;/strong&gt; Most (company, tag) and (tag, period) combinations legitimately do not exist, because companies report under different US-GAAP concepts. So the architecture question is not how to avoid 404s — it is fault isolation: one missing combination must skip one row, never sink the run. Plus three operational musts: a descriptive &lt;code&gt;User-Agent&lt;/code&gt; (SEC's Fair Access policy blocks you without one), 10 requests/second, and CIKs zero-padded to 10 digits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two endpoints, two questions
&lt;/h2&gt;

&lt;p&gt;The API answers two different shapes of question and it is worth picking deliberately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;frames&lt;/code&gt;&lt;/strong&gt; — one concept, one period, &lt;em&gt;every&lt;/em&gt; filer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/xbrl/frames/us-gaap/Assets/USD/CY2023Q1I.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a cross-section: total assets for every company as of Q1 2023. Perfect for screening and peer comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;companyconcept&lt;/code&gt;&lt;/strong&gt; — one filer, one concept, &lt;em&gt;full history&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/xbrl/companyconcept/CIK0000320193/us-gaap/Revenues.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a time series. Perfect for trend work on a specific company.&lt;/p&gt;

&lt;p&gt;Same data, transposed. Frames mode gives you a wide market snapshot; company mode gives you depth on names you already care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frame id format is stricter than it looks
&lt;/h2&gt;

&lt;p&gt;Frame ids look casual and are not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CY2023      annual
CY2023Q1    quarterly (duration)
CY2023Q1I   instant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That trailing &lt;code&gt;I&lt;/code&gt; is the difference between a &lt;em&gt;flow&lt;/em&gt; and a &lt;em&gt;stock&lt;/em&gt;. Revenue is a flow — it happens over a quarter, so it lives in &lt;code&gt;CY2023Q1&lt;/code&gt;. Assets are a stock — they exist at a moment, so they live in &lt;code&gt;CY2023Q1I&lt;/code&gt;. Ask for assets without the &lt;code&gt;I&lt;/code&gt; and you get a 404 that has nothing to do with the company and everything to do with accounting.&lt;/p&gt;

&lt;p&gt;We validate the id shape at the input boundary rather than letting SEC's 404 be the error message:&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;FRAME_PERIOD_RE&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;^CY\d{4}(Q[1-4])?I?$&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 malformed period is a mistake we can name precisely. A 404 is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why most 404s are correct answers
&lt;/h2&gt;

&lt;p&gt;US-GAAP is a large vocabulary and a filer only reports the concepts that describe its business. Here is the same tag against two filers, checked live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CIK0000320193 (Apple)      InterestAndDividendIncomeOperating -&amp;gt; 404
CIK0000019617 (JPMorgan)   InterestAndDividendIncomeOperating -&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apple is not a bank, so it has no interest-and-dividend income line to report. Nothing is wrong with the request, the tag, or the API. The concept simply does not apply.&lt;/p&gt;

&lt;p&gt;The same thing happens across revenue tags — one filer reports &lt;code&gt;Revenues&lt;/code&gt;, another &lt;code&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/code&gt;, many report both — so no single tag covers the market, and a screen built on one tag silently omits everyone who chose another.&lt;/p&gt;

&lt;p&gt;So a run over 50 companies × 3 tags will produce a lot of 404s, and that is the API working. The design consequence is that the fetch layer must never raise on one:&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;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HTTP_NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s: HTTP 404 — not reported, skipping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;None&lt;/code&gt; means "skip this combination", not "fail". Non-retryable 4xx behaves the same way. Only 408/429/5xx get retried, with capped exponential backoff.&lt;/p&gt;

&lt;p&gt;This is the single highest-leverage pattern in our whole fleet. When we audited our lowest-success-rate Actors, the top cause was never a hard block — it was a recoverable per-item error crashing the entire run, so a customer lost 400 good rows because row 401 was unusual. Every Actor now isolates per item.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEC's Fair Access rules are not optional
&lt;/h2&gt;

&lt;p&gt;Two hard requirements, both easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A descriptive &lt;code&gt;User-Agent&lt;/code&gt;.&lt;/strong&gt; SEC's Fair Access policy expects a real identifier. Send a default library UA and you will eventually get blocked — and unlike a fingerprinting wall, this one is documented policy, so complaining is not a strategy. Ours is configurable and defaults to our public brand contact URL, never a personal mailbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10 requests per second, maximum.&lt;/strong&gt; With one in-flight request at a time, a 100 ms sleep before each attempt enforces the whole limit with no token bucket, no shared state, and nothing to get wrong under concurrency:&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;MIN_REQUEST_INTERVAL_S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also honour &lt;code&gt;Retry-After&lt;/code&gt; when SEC sends it, in preference to our own backoff. If the server tells you when to come back, argue with it at your peril.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tickers in, CIKs out
&lt;/h2&gt;

&lt;p&gt;Nobody wants to look up a CIK. So the Actor accepts tickers &lt;em&gt;or&lt;/em&gt; CIKs, and resolves tickers through SEC's own public map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://www.sec.gov/files/company_tickers.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one gotcha: CIKs are &lt;strong&gt;zero-padded to 10 digits&lt;/strong&gt; in API paths. Apple is &lt;code&gt;0000320193&lt;/code&gt;, not &lt;code&gt;320193&lt;/code&gt;. The raw map gives you the integer, so the pad is on you:&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="nf"&gt;str&lt;/span&gt;&lt;span class="p"&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;cik_raw&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;zfill&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss it and every request 404s — which, given everything above, you will initially misread as "this company did not report that."&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping SEC's XBRL API legal?&lt;/strong&gt;&lt;br&gt;
It is a public API the SEC publishes for exactly this purpose. Follow the Fair Access policy — identify yourself, stay under 10 req/s — and you are inside their stated rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt;&lt;br&gt;
No. It is keyless. The &lt;code&gt;User-Agent&lt;/code&gt; is the closest thing to identification and it is required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is &lt;code&gt;start_date&lt;/code&gt; null on some rows?&lt;/strong&gt;&lt;br&gt;
Because instant facts (balance-sheet items) have no duration — only an &lt;code&gt;end_date&lt;/code&gt;. That is expected, not a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which tags should I use?&lt;/strong&gt;&lt;br&gt;
Start with &lt;code&gt;Revenues&lt;/code&gt;, &lt;code&gt;Assets&lt;/code&gt;, &lt;code&gt;NetIncomeLoss&lt;/code&gt; — widely reported across sectors — then widen. Any US-GAAP, IFRS-full or DEI concept works, and coverage varies by filer and by industry, which is what makes the 404-tolerant design necessary rather than defensive.&lt;/p&gt;




&lt;p&gt;Ready to run: &lt;strong&gt;&lt;a href="https://apify.com/DevilScrapes/sec-xbrl-financials-scraper" rel="noopener noreferrer"&gt;SEC XBRL Financials Scraper&lt;/a&gt;&lt;/strong&gt; — frame mode for one concept across every filer, company mode for one filer's full history; tickers or CIKs, any US-GAAP/IFRS/DEI tag, out to JSON, CSV, or Excel.&lt;/p&gt;

&lt;p&gt;We do the dirty work so your dataset stays clean. 😈&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>finance</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>GBIF has a hard wall at exactly 100,001 records</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:27:39 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/gbif-has-a-hard-wall-at-exactly-100001-records-5b6n</link>
      <guid>https://dev.to/devil_scrapes/gbif-has-a-hard-wall-at-exactly-100001-records-5b6n</guid>
      <description>&lt;p&gt;GBIF — the Global Biodiversity Information Facility — indexes over three billion species occurrence records from museums, herbaria, national surveys and citizen-science platforms. It is keyless, well documented, and generous. It also has a hard wall at exactly 100,001 records that its docs mention in passing, and a name-resolution step that most first drafts skip entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Two things decide whether a GBIF scraper works. &lt;strong&gt;First: resolve the species name to a &lt;code&gt;usageKey&lt;/code&gt; via &lt;code&gt;species/match&lt;/code&gt; before you search&lt;/strong&gt; — free-text &lt;code&gt;scientificName&lt;/code&gt; matching is fuzzy and silently drops synonyms. &lt;strong&gt;Second: &lt;code&gt;offset + limit&lt;/code&gt; must stay under 100,001&lt;/strong&gt; or you get HTTP 400 mid-run. We verified the wall live: &lt;code&gt;offset=200000&lt;/code&gt; answers &lt;code&gt;400 "Max offset of 100001 exceeded: 200000 + 1"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why resolve the name first?
&lt;/h2&gt;

&lt;p&gt;You want &lt;em&gt;Panthera leo&lt;/em&gt;. So does everyone else — but a species can be recorded under a synonym, a misspelling, a subspecies, or an outdated genus, and taxonomic backbones exist precisely to reconcile that.&lt;/p&gt;

&lt;p&gt;GBIF exposes the reconciliation as its own endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.gbif.org/v1/species/match?name=Panthera+leo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hands back a &lt;code&gt;usageKey&lt;/code&gt; — a stable integer for the accepted taxon — plus a &lt;code&gt;matchType&lt;/code&gt; and a confidence score. Search by &lt;code&gt;taxonKey=&amp;lt;usageKey&amp;gt;&lt;/code&gt; and you get the occurrences GBIF's backbone considers that species, synonyms included. Search by free-text name and you get whatever string-matched.&lt;/p&gt;

&lt;p&gt;The response also carries a &lt;code&gt;matchType&lt;/code&gt; and a confidence score, and they are worth reading. &lt;code&gt;EXACT&lt;/code&gt; is what you want; &lt;code&gt;FUZZY&lt;/code&gt; means GBIF guessed, and a fuzzy match on a typo can hand you a completely different organism with a perfectly successful HTTP 200 attached.&lt;/p&gt;

&lt;p&gt;Our Actor logs the resolution it actually used — the key plus the canonical &lt;code&gt;scientificName&lt;/code&gt; GBIF landed on — so the run log tells you &lt;em&gt;Panthera leo&lt;/em&gt; resolved to &lt;em&gt;Panthera leo&lt;/em&gt; and not to something adjacent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resolved 'Panthera leo' -&amp;gt; taxonKey 5219404 (Panthera leo (Linnaeus, 1758))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;species/match&lt;/code&gt; finds nothing at all, it says so and falls back to an unfiltered-by-taxon search rather than silently returning zero rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 100,001-record wall
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;occurrence/search&lt;/code&gt; pages with &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt;. &lt;code&gt;limit&lt;/code&gt; maxes at 300. &lt;code&gt;offset&lt;/code&gt; is where it gets interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;offset=100000  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200 OK
&lt;span class="gp"&gt;offset=200000  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;400 &lt;span class="s2"&gt;"Max offset of 100001 exceeded: 200000 + 1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified live, not read off a doc page. So the real constraint is on the &lt;em&gt;sum&lt;/em&gt;: &lt;code&gt;offset + limit &amp;lt;= 100001&lt;/code&gt;. A loop that pages happily to record 99,900 and then requests &lt;code&gt;offset=99900&amp;amp;limit=300&lt;/code&gt; is asking for 100,200 and gets a 400 — a crash at the very end of a long, expensive run, which is the most infuriating place to put one.&lt;/p&gt;

&lt;p&gt;We cap the walk at 100,000 so every request stays inside GBIF's boundary, and we say so in the Actor's own docs rather than letting a customer discover it at row 100,001. If you need more than that, the answer is not a cleverer offset — it is GBIF's asynchronous download API, which is a different tool with a different contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enumerations: read them from the API, do not type them
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;basisOfRecord&lt;/code&gt; (is this a preserved specimen, a human observation, a machine observation, a fossil?) is a closed enumeration. It is tempting to hardcode the four or five values you have seen.&lt;/p&gt;

&lt;p&gt;GBIF publishes the authoritative list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.gbif.org/v1/enumeration/basic/BasisOfRecord
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We pulled it live and generated the input's allowed values from that, rather than from memory. It costs one request at build time and removes a whole class of "why does this filter return nothing?" support question — the same class of bug as an invented enum value in a platform manifest, which we have shipped before and would rather not again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about one malformed record in three billion?
&lt;/h2&gt;

&lt;p&gt;It should cost you one record. GBIF aggregates from thousands of independent publishers with wildly varying data quality — a coordinate that is a string, a date that is a year, an occurrence with no taxonomy at all. All of that is normal.&lt;/p&gt;

&lt;p&gt;So parsing is per-record and failures are skipped, logged, and counted:&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_occurrence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;   &lt;span class="c1"&gt;# skip this one, keep the run
&lt;/span&gt;    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most common cause of a low-success-rate scraper in our fleet has never been a hard block. It is a recoverable per-item error taking down the entire run — one weird row costing the customer all the good ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is scraping GBIF legal?
&lt;/h2&gt;

&lt;p&gt;GBIF exists to publish this data for reuse, and most records carry an explicit open licence (CC0 or CC-BY) which the API returns per record. Honour the per-record licence when you redistribute, cite the datasets, and identify your client in the &lt;code&gt;User-Agent&lt;/code&gt;. That is the whole etiquette.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a key or a proxy?&lt;/strong&gt;&lt;br&gt;
Neither. It is a public keyless API with no anti-bot surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many records can one run return?&lt;/strong&gt;&lt;br&gt;
Up to 100,000 via the search API, which is the platform's own ceiling. Larger extractions belong to GBIF's asynchronous download endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my species name return nothing?&lt;/strong&gt;&lt;br&gt;
Check the match type. A &lt;code&gt;NONE&lt;/code&gt; or low-confidence &lt;code&gt;FUZZY&lt;/code&gt; match means the backbone did not recognise the name — usually a spelling or an authorship string that needs trimming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I filter by country and year together?&lt;/strong&gt;&lt;br&gt;
Yes, and you should — it is the cheapest way to keep a query under the offset ceiling while still getting the slice you actually want.&lt;/p&gt;




&lt;p&gt;Ready to run: &lt;strong&gt;&lt;a href="https://apify.com/DevilScrapes/gbif-species-occurrence-scraper" rel="noopener noreferrer"&gt;GBIF Species Occurrence Scraper&lt;/a&gt;&lt;/strong&gt; — search by species name, country, year range, or dataset; get full taxonomy, event date, coordinates, basis of record and dataset provenance as JSON, CSV, or Excel.&lt;/p&gt;

&lt;p&gt;We do the dirty work so your dataset stays clean. 😈&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>datascience</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Our smoke test was green and returned zero rows</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 15:12:50 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/our-smoke-test-was-green-and-returned-zero-rows-mo8</link>
      <guid>https://dev.to/devil_scrapes/our-smoke-test-was-green-and-returned-zero-rows-mo8</guid>
      <description>&lt;p&gt;Our first cloud smoke test of the FDA 510(k) scraper came back green. Status: SUCCEEDED. Rows: zero.&lt;/p&gt;

&lt;p&gt;The scraper was fine. The &lt;em&gt;example input&lt;/em&gt; was the bug — and the verifier we built specifically to catch bad example inputs passed it, correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;A prefill (Apify's example input) that validates is not a prefill that returns data. Ours carried a value for &lt;strong&gt;every&lt;/strong&gt; filter, so the smoke run ANDed applicant AND device name AND product code AND advisory committee AND device class AND a date range into a query openFDA legitimately matches nothing for. &lt;strong&gt;Validation checks shape; only a live query checks that the shape matches reality.&lt;/strong&gt; Three more openFDA traps below: &lt;code&gt;skip&lt;/code&gt; caps at 25,000, a &lt;code&gt;+&lt;/code&gt; in a search term returns HTTP 500, and a no-match search answers 404 rather than an empty list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green run with no rows
&lt;/h2&gt;

&lt;p&gt;We have a pre-publish gate called &lt;code&gt;verify_input_prefill.py&lt;/code&gt;. Its job is to assemble the input schema's &lt;code&gt;prefill&lt;/code&gt; and &lt;code&gt;default&lt;/code&gt; values into a payload and confirm the Actor's Pydantic input model accepts it. It exists because a prefill that fails validation means a customer's first click errors out.&lt;/p&gt;

&lt;p&gt;It passed. It was right to pass. Every field was well-typed, every date well-formed, the payload validated cleanly.&lt;/p&gt;

&lt;p&gt;And the run returned nothing, because openFDA's &lt;code&gt;search&lt;/code&gt; parameter ANDs its clauses, and the intersection of six independent filters over a database of device clearances is empty far more often than it is not. Every individual filter was plausible. The conjunction was a query about a device that does not exist.&lt;/p&gt;

&lt;p&gt;The fix was to the prefill, not the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before: applicant + device name + product code + committee + class + date range
after:  applicant + date range          # confirmed live to match 24 records
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-pushed, re-QA'd, rows arrived.&lt;/p&gt;

&lt;p&gt;The general lesson we wrote into our build queue: &lt;strong&gt;a smoke test must assert on rows, not on exit status.&lt;/strong&gt; "SUCCEEDED" is a statement about our process. Rows are a statement about the customer's experience. We had been treating the first as evidence of the second, which is the same conflation as treating "the tests pass" as evidence that the feature works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: &lt;code&gt;skip&lt;/code&gt; caps at 25,000 — and not where you think
&lt;/h2&gt;

&lt;p&gt;openFDA pages with &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;skip&lt;/code&gt;. &lt;code&gt;limit&lt;/code&gt; maxes at 1,000. &lt;code&gt;skip&lt;/code&gt; refuses to go past 25,000:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;skip=25000 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200 OK
&lt;span class="gp"&gt;skip=25001 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;400 &lt;span class="s2"&gt;"Skip value must 25000 or less."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a specific trap here for anyone who has already written an openFDA scraper. We ship a sibling Actor against the &lt;strong&gt;recalls&lt;/strong&gt; endpoint, and that one has a different ceiling. Copying the constant across endpoints produces a scraper that dies 1,000 rows early or 1,000 rows late, depending on which direction you copied. Each openFDA endpoint gets its own verified constant, checked against the live API, with the verification date in the comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a &lt;code&gt;+&lt;/code&gt; in a search term returns HTTP 500
&lt;/h2&gt;

&lt;p&gt;This one is pure encoding. openFDA's query language treats &lt;code&gt;+&lt;/code&gt; as meaningful, so a company name containing a literal &lt;code&gt;+&lt;/code&gt; — url-encoded to &lt;code&gt;%2B&lt;/code&gt; — makes the server answer 500. Encode the same character as a space (&lt;code&gt;%20&lt;/code&gt;) and it parses fine.&lt;/p&gt;

&lt;p&gt;It is worth naming because a 500 reads as "their server is having a bad day", which invites a retry loop rather than a fix. It is deterministic: same input, same 500, forever. Our sibling recalls Actor hit exactly this and the fix transfers unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: no matches is a 404, not an empty list
&lt;/h2&gt;

&lt;p&gt;Search for something real that simply has no records and openFDA answers &lt;strong&gt;404&lt;/strong&gt;, not &lt;code&gt;{"results": []}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a client that treats 404 as a hard error reports "the API is broken" when the honest answer is "your filter matched nothing". And a client that retries 404s burns its retry budget on a settled question. We treat this one status as a terminal, non-error, zero-results outcome, and say so in the log.&lt;/p&gt;

&lt;p&gt;There is a fourth, smaller one worth knowing: a cold query can 500 once and then 200 on an immediate retry. Genuinely transient, unlike trap 2. Bounded retries with backoff on 408/429/5xx cover it — but only because trap 2's deterministic 500 was fixed at the source rather than retried into the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in a 510(k) record?
&lt;/h2&gt;

&lt;p&gt;The premarket notification pathway is how most medical devices reach the US market — a claim of substantial equivalence to an already-cleared device. Each record carries the applicant, the device name, the decision date and decision code, the product code, the advisory committee, the device class and the regulation number.&lt;/p&gt;

&lt;p&gt;That is a regulatory-intelligence dataset: who is clearing what, in which class, how fast, and through which committee. Filter by applicant to watch a competitor, by product code to watch a device category, or by decision-date range to build a clearance timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping openFDA legal?&lt;/strong&gt;&lt;br&gt;
openFDA is the FDA's own public API, published for reuse, no key required for normal use. Read their disclaimer about the limits of the underlying data before you make decisions with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key?&lt;/strong&gt;&lt;br&gt;
No. Keys exist only to raise rate limits; the unauthenticated tier is enough for ordinary extraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many records can one run pull?&lt;/strong&gt;&lt;br&gt;
Up to openFDA's own &lt;code&gt;skip&lt;/code&gt; ceiling of 25,000 per query — its own 400 response points at &lt;code&gt;search_after&lt;/code&gt; for going deeper. Until we wire that up, narrow by date range or product code and slice a bigger set into runs that each stay inside the ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my query return nothing?&lt;/strong&gt;&lt;br&gt;
Almost always because the filters ANDed into an empty intersection — the exact bug that produced our zero-row green run. Drop filters one at a time until rows appear.&lt;/p&gt;




&lt;p&gt;Ready to run: &lt;strong&gt;&lt;a href="https://apify.com/DevilScrapes/fda-510k-clearances-scraper" rel="noopener noreferrer"&gt;FDA 510(k) Clearances Scraper&lt;/a&gt;&lt;/strong&gt; — filter openFDA 510(k) clearances by applicant, device name, product code, advisory committee, device class, or decision-date range; export as JSON, CSV, or Excel.&lt;/p&gt;

&lt;p&gt;We do the dirty work so your dataset stays clean. 😈&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>api</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Crossref's funder filter wants a DOI, not a name — and 3 more traps</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:37:37 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/crossrefs-funder-filter-wants-a-doi-not-a-name-and-3-more-traps-42pl</link>
      <guid>https://dev.to/devil_scrapes/crossrefs-funder-filter-wants-a-doi-not-a-name-and-3-more-traps-42pl</guid>
      <description>&lt;p&gt;Crossref's &lt;code&gt;works&lt;/code&gt; endpoint is about as friendly as a public API gets: no key, no rate-limit paperwork, 150 million-plus DOIs, and documentation that mostly matches reality. We shipped a scraper against it in an afternoon.&lt;/p&gt;

&lt;p&gt;It still had four traps in it, and every one of them is the kind that passes a smoke test and fails on a customer's first real query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;The four things that will bite you scraping Crossref: &lt;strong&gt;&lt;code&gt;filter=funder:&lt;/code&gt; accepts a Funder Registry DOI, not a funder name&lt;/strong&gt;; &lt;strong&gt;dates arrive as &lt;code&gt;date-parts&lt;/code&gt; arrays that are legitimately 1, 2, or 3 elements long&lt;/strong&gt;; &lt;strong&gt;author names come in four different shapes&lt;/strong&gt;; and &lt;strong&gt;&lt;code&gt;next-cursor&lt;/code&gt; can hand you the same cursor forever&lt;/strong&gt;. None of them raise an exception. All four produce plausible, wrong output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scrape Crossref instead of Google Scholar?
&lt;/h2&gt;

&lt;p&gt;Because Crossref will let you. It is the DOI registration agency behind most of scholarly publishing, and the metadata publishers submit — title, journal, authors, ORCIDs, funders, award numbers, licence, citation counts — is exposed through one keyless JSON endpoint on purpose. There is no bot detection to defeat, which means the engineering budget goes into being correct rather than into being invisible.&lt;/p&gt;

&lt;p&gt;The polite convention is worth honouring: put a contactable identifier in your &lt;code&gt;User-Agent&lt;/code&gt; and Crossref routes you to a better-served pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;User-Agent: crossref-works-scraper/0.1.0 (https://apify.com/DevilScrapes)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a brand contact URL, not a personal mailbox. Works the same, leaks nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the funder filter wants a DOI
&lt;/h2&gt;

&lt;p&gt;This looks like it should work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /works?filter=funder:Wellcome%20Trust
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns 200. It returns works. It does not return works funded by the Wellcome Trust — &lt;code&gt;funder:&lt;/code&gt; is matched against the Funder Registry &lt;strong&gt;DOI&lt;/strong&gt;, so a free-text name matches nothing useful and the API does not consider that an error. You get a full, confident, wrong result set.&lt;/p&gt;

&lt;p&gt;We split the input on shape instead of trusting the user to know:&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;FUNDER_DOI_RE&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;^10\.\d{4,9}/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;funder_is_doi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;funder_name&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;FUNDER_DOI_RE&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;funder_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A DOI goes to Crossref as a server-side filter. A name becomes a client-side, case-insensitive substring match over each work's funder list. Same field, two paths, and "Wellcome" finds the works whether the publisher wrote &lt;em&gt;Wellcome Trust&lt;/em&gt; or &lt;em&gt;The Wellcome Trust&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: &lt;code&gt;date-parts&lt;/code&gt; is not a date
&lt;/h2&gt;

&lt;p&gt;Crossref publication dates look like this:&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;"published"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date-parts"&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="mi"&gt;2021&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except when the publisher only registered a year and a month. Or only a year. All three are valid, all three are common, and the array length tells you which you got:&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;if&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;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;parts&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;              &lt;span class="c1"&gt;# 2021
&lt;/span&gt;&lt;span class="k"&gt;if&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;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;parts&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&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;parts&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# 2021-03
&lt;/span&gt;&lt;span class="k"&gt;return&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;parts&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;04&lt;/span&gt;&lt;span class="n"&gt;d&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;parts&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&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;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tempting shortcut — &lt;code&gt;datetime(*parts)&lt;/code&gt; with defaults — invents a 1 January that no publisher ever claimed. That is worse than a null, because a null is visibly missing and a fabricated date silently poisons every date-range query downstream.&lt;/p&gt;

&lt;p&gt;There is a second half to this: the date lives under different keys depending on the record. We walk a fallback chain — &lt;code&gt;published&lt;/code&gt;, then &lt;code&gt;published-print&lt;/code&gt;, then &lt;code&gt;published-online&lt;/code&gt;, then &lt;code&gt;issued&lt;/code&gt; — and take the first one that renders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: authors have four name shapes
&lt;/h2&gt;

&lt;p&gt;A Crossref author entry can carry &lt;code&gt;given&lt;/code&gt; + &lt;code&gt;family&lt;/code&gt;, only &lt;code&gt;family&lt;/code&gt;, a &lt;code&gt;literal&lt;/code&gt; string (common for consortia and corporate authors), or a bare &lt;code&gt;name&lt;/code&gt;. Read only &lt;code&gt;given&lt;/code&gt;/&lt;code&gt;family&lt;/code&gt; and every consortium byline in your dataset comes back empty.&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;_author_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&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;given&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;entry&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;family&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;given&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;given&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;family&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;entry&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;literal&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="n"&gt;entry&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;name&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;ORCIDs have the same shape problem in miniature — sometimes bare, sometimes prefixed with &lt;code&gt;https://orcid.org/&lt;/code&gt;. Store one form, always, or your joins will quietly miss half the matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: the cursor that never ends
&lt;/h2&gt;

&lt;p&gt;Deep paging on Crossref is cursor-based, and that is genuinely the right design — offset paging on a 150M-record corpus hits a wall a few thousand rows in. You pass &lt;code&gt;cursor=*&lt;/code&gt;, and each response hands back &lt;code&gt;message.next-cursor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failure mode is that &lt;code&gt;next-cursor&lt;/code&gt; is not guaranteed to advance. Hand back the same token twice and a naive &lt;code&gt;while next_cursor:&lt;/code&gt; loop pages forever, re-emitting the same block of works and — on a pay-per-result Actor — billing the customer for every duplicate.&lt;/p&gt;

&lt;p&gt;One line of defence:&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;previous_cursor&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;emitted&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
&lt;span class="n"&gt;previous_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent exits: no cursor, a stalled cursor, or the row budget spent. A paging loop should never have only one way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a single malformed record?
&lt;/h2&gt;

&lt;p&gt;It should cost you that record and nothing else. The general lesson from our fleet is that the top cause of a low-success scraper is not a hard block — it is a recoverable per-item error that takes down the whole run. Crossref metadata is publisher-submitted and therefore inconsistent by nature, so parsing is wrapped per item:&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;_safe_parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse_work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# log it, skip it, keep going
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine hundred and ninety-nine good rows plus one weird preprint should be 999 rows delivered, not a failed run.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping Crossref legal?&lt;/strong&gt;&lt;br&gt;
Crossref publishes this metadata through a public API for exactly this kind of reuse, and most of it is explicitly open. Honour the polite-pool convention, do not hammer it, and check the licence field on anything you redistribute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an API key or a proxy?&lt;/strong&gt;&lt;br&gt;
Neither. It is keyless, and a public API has no reason to block a well-behaved client. Our Actor leaves the proxy off by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How deep can I page?&lt;/strong&gt;&lt;br&gt;
As deep as the result set goes, if you use the cursor. Offset paging is what has a practical ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why are some works missing abstracts, ISSNs or funders?&lt;/strong&gt;&lt;br&gt;
Because publishers did not submit them. Scholarly metadata is sparse by default — build your schema to accept nulls everywhere rather than assuming a fully-populated record.&lt;/p&gt;




&lt;p&gt;We packaged all of this as a ready-to-run Actor: &lt;strong&gt;&lt;a href="https://apify.com/DevilScrapes/crossref-works-scraper" rel="noopener noreferrer"&gt;Crossref Works Scraper&lt;/a&gt;&lt;/strong&gt; — search by bibliographic query, publication-date range, work type, publisher, funder, or ORCID/abstract/full-text presence, and get one flat row per work with authors, ORCIDs, funders, award numbers, licence and citation count. Exports to JSON, CSV, or Excel.&lt;/p&gt;

&lt;p&gt;We do the dirty work so your dataset stays clean. 😈&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Your proxy returned 200 OK and the wrong country's data</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 04:32:20 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/your-proxy-returned-200-ok-and-the-wrong-countrys-data-n4g</link>
      <guid>https://dev.to/devil_scrapes/your-proxy-returned-200-ok-and-the-wrong-countrys-data-n4g</guid>
      <description>&lt;p&gt;The worst scraping bug we have shipped did not throw an exception. It returned HTTP 200, a full page of well-formed listings, every field populated, every assertion green — and the data was from the wrong country.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A rotating residential proxy pool will happily give you an exit IP in a country you did not ask for, and a geo-aware site will then serve you plausible, correct-looking data for that country instead.&lt;/strong&gt; There is no error to catch. The defence is two-part: pin the exit country explicitly in your proxy configuration, and then &lt;em&gt;verify the country in the response body&lt;/em&gt; before you emit a single row. If you only do the first, you are trusting a config flag with no feedback loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a 200 OK lies to you
&lt;/h2&gt;

&lt;p&gt;Geo-targeted sites do not usually reject a foreign visitor. They localise for them. Ask a big job board for "developer jobs in Sydney" from a German exit IP and you may well get a working page — correct HTML, real listings, sensible salaries — for an entirely different market. Or a market splash page. Or the right search, silently region-filtered.&lt;/p&gt;

&lt;p&gt;Every failure signal your scraper watches for is absent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Status code: &lt;code&gt;200&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Content length: normal&lt;/li&gt;
&lt;li&gt;Parse: succeeds&lt;/li&gt;
&lt;li&gt;Required fields: all present&lt;/li&gt;
&lt;li&gt;Row count: as expected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing wrong is the answer. And because rotation is random, it is wrong &lt;em&gt;intermittently&lt;/em&gt; — maybe one page in eight, depending on your pool's composition. That is the signature that makes this expensive: intermittent, silent, and invisible to every test that checks structure rather than meaning.&lt;/p&gt;

&lt;p&gt;We now treat this as its own bug class. A wrong-country row is worse than a failed request, because a failed request is loud and a wrong row gets into someone's dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — pin the country, do not hope for it
&lt;/h2&gt;

&lt;p&gt;Most proxy providers let you constrain the exit country. On Apify Proxy, that is &lt;code&gt;countryCode&lt;/code&gt; on the residential group:&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;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_proxy_configuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;groups&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;RESIDENTIAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave that off and you get "some residential IP, somewhere". For a national job board, that is not a proxy configuration, it is a lottery ticket.&lt;/p&gt;

&lt;p&gt;Worth knowing: &lt;strong&gt;requesting a tier is not the same as using one.&lt;/strong&gt; We have measured runs that passed a residential configuration and billed zero residential bytes — one preferred an environment variable over the input we handed it, another's proxy helper failed and silently degraded to a direct connection. The only honest check is the run's billing record. If the residential transfer line is zero, residential was not used, whatever your config said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — verify geography in the response
&lt;/h2&gt;

&lt;p&gt;Pinning is necessary and not sufficient. The response itself has to be interrogated before it is trusted. For an Australian job board that means checking the site's own market signal and each posting's country code, and treating a mismatch as a &lt;em&gt;retryable failure&lt;/em&gt;, not as data:&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;looks_like_target_market&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;geo mismatch — rotating session and retrying&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;rotate_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RetryableError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrong market&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 is the whole guard. Rotate the session ID so you get a fresh exit IP, retry, and only emit rows once the market check passes. It costs one extra comparison per response and it converts an invisible data-quality defect into an ordinary, visible retry.&lt;/p&gt;

&lt;p&gt;The general form, for any geo-sensitive target: &lt;strong&gt;find the field in the response that names the market, and assert on it.&lt;/strong&gt; Currency symbol, country code, locale string, market identifier, a phone format — something the page states about itself. If a target does not expose one, that absence is itself a finding, and you should be far more conservative about what you claim the data represents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use one static IP?
&lt;/h2&gt;

&lt;p&gt;Because you will get blocked, and then you will have neither correctness nor coverage. Rotation is not the enemy here; &lt;em&gt;unconstrained&lt;/em&gt; rotation is. You want a pool that rotates within a country, plus a check that confirms it did. Rotation gives you resilience, pinning gives you correctness, and verification gives you proof — you need all three, and most implementations stop after the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else Seek actually needs
&lt;/h2&gt;

&lt;p&gt;The geo guard is the interesting part, but it is not the only part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real browser TLS.&lt;/strong&gt; We use &lt;code&gt;curl-cffi&lt;/code&gt; impersonation so the handshake and HTTP/2 fingerprint look like Chrome, Firefox or Safari rather than Python's. Fingerprint mismatch is one of the quieter reasons a scraper gets nothing back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backoff that honours &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/strong&gt; On &lt;code&gt;408 / 429 / 503&lt;/code&gt;, exponential backoff up to five attempts, and when the server tells you how long to wait, wait that long. Ignoring &lt;code&gt;Retry-After&lt;/code&gt; is how a temporary throttle becomes a permanent block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taxonomy preservation.&lt;/strong&gt; Seek classifies roles with its own classification and subclassification scheme. Flattening that to a free-text string throws away the most useful filtering dimension in the dataset, so we keep both levels intact alongside the suburb → state → country location hierarchy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/seek-australia-jobs-scraper" rel="noopener noreferrer"&gt;Seek Australia Jobs Scraper&lt;/a&gt; searches &lt;code&gt;au.seek.com&lt;/code&gt; by keyword and Australian location and returns one normalized row per posting — title, company, classification and subclassification, flattened location hierarchy, work type and arrangement, salary label, listing date and teaser. No login, no API key. Pay-per-result at &lt;strong&gt;$1.83 per 1,000 rows&lt;/strong&gt;, so a search that returns nothing costs nothing beyond the start fee.&lt;/p&gt;

&lt;p&gt;We are not going to tell you Seek is easy to scrape. Job boards actively defend their listings, they change those defences without notice, and the honest framing is that absorbing the blocks, the rotation, the retries and the geo verification is our job, not yours. When it does start pushing back, that is our problem to fix, not a support ticket for you to write.&lt;/p&gt;

&lt;p&gt;If you need the same treatment on other markets, the neighbours are &lt;a href="https://apify.com/DevilScrapes/smartrecruiters-jobs-scraper" rel="noopener noreferrer"&gt;SmartRecruiters&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/workable-jobs-scraper" rel="noopener noreferrer"&gt;Workable&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/greenhouse-jobs-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt; and the &lt;a href="https://apify.com/DevilScrapes/multi-ats-jobs-scraper" rel="noopener noreferrer"&gt;multi-ATS aggregator&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we wrote down
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Geo-random residential exits return plausible &lt;strong&gt;wrong&lt;/strong&gt; data with a 200 status, not errors. Pin the country, then verify the country in the body. A proxy config you never validate against the response is a hypothesis, not a control.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The JSON Schema bug that only fires on a customer's first real query</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 04:31:49 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/the-json-schema-bug-that-only-fires-on-a-customers-first-real-query-34g2</link>
      <guid>https://dev.to/devil_scrapes/the-json-schema-bug-that-only-fires-on-a-customers-first-real-query-34g2</guid>
      <description>&lt;p&gt;There is a category of bug that passes every test you write, ships to production, and then fails on a customer's very first real query. We hit a clean example of it building against ClinicalTrials.gov, and the root cause was four characters of JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If your output schema declares &lt;code&gt;"type": "string"&lt;/code&gt; for a field that is sometimes absent, your pipeline will die on the first sparse record — and no realistic test fixture will catch it.&lt;/strong&gt; Registry data is sparse by nature: a Phase 1 trial has no results, an observational study has no intervention arm, a terminated trial has no completion date. Declare every optional field as &lt;code&gt;"type": ["string", "null"]&lt;/code&gt;. A QA sample chosen because it looks good is the &lt;em&gt;worst&lt;/em&gt; fixture you can pick, because it hides exactly this class of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Apify Actors can publish a dataset schema so downstream consumers get a typed, validated table instead of loose JSON. Ours declared fields the obvious way:&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;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"primary_completion_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enrollment_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads as correct. It is not, and here is why it is dangerous rather than merely wrong: validation happens &lt;strong&gt;at write time, per record&lt;/strong&gt;. So the run starts, streams a few hundred perfectly good rows, and then hits a trial with no primary completion date. &lt;code&gt;null&lt;/code&gt; is not a &lt;code&gt;string&lt;/code&gt;. The write is rejected, the exception propagates, and the whole run dies — &lt;em&gt;after&lt;/em&gt; the customer has been charged for the rows that already landed.&lt;/p&gt;

&lt;p&gt;The correct declaration:&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;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"primary_completion_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enrollment_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four characters. The interesting part is not the fix, it is why it survived testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a good test fixture hid a real bug
&lt;/h2&gt;

&lt;p&gt;When you build a fixture, you naturally reach for a &lt;em&gt;representative&lt;/em&gt; record — one that exercises the parser, shows off the fields, and reads well in a code review. For registry data, that means a large, well-funded, fully-reported interventional trial. Every field populated. Every assertion meaningful.&lt;/p&gt;

&lt;p&gt;That fixture cannot fail this bug. Not "is unlikely to" — &lt;em&gt;cannot&lt;/em&gt;. The failure requires a null, and you deliberately picked a record that has none.&lt;/p&gt;

&lt;p&gt;The same applies to smoke tests. A three-row smoke test against the default query hits the most complete, most-cited, best-maintained records in the registry. It passes. Then a customer filters for terminated trials in a rare disease, gets a result set that is 60% sparse, and the run dies on row 4.&lt;/p&gt;

&lt;p&gt;We changed two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fixtures must include the ugliest record we can find&lt;/strong&gt;, not the prettiest. Withdrawn studies, missing sponsors, empty location arrays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scaffolder now emits nullable unions by default&lt;/strong&gt;, so no new Actor can be born with the bug. The fix belongs at the template, not in a checklist item someone has to remember.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why is the ClinicalTrials.gov API hard to use?
&lt;/h2&gt;

&lt;p&gt;Not because it blocks you — it is a public, keyless, well-run government API and it does not fight back at all. It is hard because of &lt;strong&gt;shape&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The v2 API returns deeply nested protocol sections that mirror the regulatory document structure, not anything you would want in a spreadsheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;protocolSection
├── identificationModule      → nctId, briefTitle, officialTitle
├── statusModule              → overallStatus, startDateStruct, completionDateStruct
├── sponsorCollaboratorsModule→ leadSponsor.name, collaborators[]
├── designModule              → phases[], enrollmentInfo.count, studyType
├── conditionsModule          → conditions[], keywords[]
├── armsInterventionsModule   → interventions[].type, .name
└── contactsLocationsModule   → locations[].facility, .city, .country
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every leaf is optional, several are arrays that are sometimes absent and sometimes empty, and the date fields are structs with their own optional precision qualifier rather than plain strings. Flattening that into one predictable row per trial is the actual work, and it is the part that produces nulls everywhere — which loops straight back to the schema bug above.&lt;/p&gt;

&lt;p&gt;The API also uses &lt;strong&gt;cursor paging&lt;/strong&gt; (&lt;code&gt;nextPageToken&lt;/code&gt;), not offset. That is the right choice on their side and a small adjustment on yours: page until the token comes back absent, and do not try to compute a page count up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry the registry's 5xx
&lt;/h2&gt;

&lt;p&gt;One more that costs a run if you skip it: ClinicalTrials.gov returns transient 5xx under load, and a single one will kill an unguarded long query. Retry with exponential backoff and treat 5xx as retryable rather than fatal. The same rule we apply to every government API we touch — these are public services under real load, not enterprise SLAs, and intermittent failure is normal operating behaviour rather than an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/clinicaltrials-gov-scraper" rel="noopener noreferrer"&gt;ClinicalTrials.gov Scraper&lt;/a&gt; searches the registry and returns one flat row per trial — NCT ID, title, phase, status, sponsor, conditions, interventions, enrollment, locations, and start and completion dates — as JSON, CSV or Excel. It pages the cursor API, stops exactly on your result cap, and retries transient registry 5xx instead of failing the run. Pay-per-result at &lt;strong&gt;$2.05 per 1,000 rows&lt;/strong&gt;, so a query that returns nothing costs nothing beyond the start fee.&lt;/p&gt;

&lt;p&gt;We are not going to claim registry data is tidy. It is a regulatory filing system with twenty years of schema evolution in it, and absorbing the nesting, the nulls, the cursor paging and the retries is our job, not yours.&lt;/p&gt;

&lt;p&gt;If you work adjacent regulatory sources, the neighbours are &lt;a href="https://apify.com/DevilScrapes/fda-recalls-scraper" rel="noopener noreferrer"&gt;FDA recalls&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/npi-healthcare-provider-scraper" rel="noopener noreferrer"&gt;NPI healthcare providers&lt;/a&gt; and &lt;a href="https://apify.com/DevilScrapes/pubmed-papers-scraper" rel="noopener noreferrer"&gt;PubMed papers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule worth stealing
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Your test fixture should be the worst record in the dataset, not the best one. A fixture chosen for how well it demonstrates the happy path is a fixture guaranteed to miss the sparse-record bug.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>datascience</category>
      <category>testing</category>
    </item>
    <item>
      <title>openFDA returns HTTP 500 when you follow its own docs</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 04:31:18 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/openfda-returns-http-500-when-you-follow-its-own-docs-4h7l</link>
      <guid>https://dev.to/devil_scrapes/openfda-returns-http-500-when-you-follow-its-own-docs-4h7l</guid>
      <description>&lt;p&gt;openFDA's documentation tells you to join search terms with &lt;code&gt;+AND+&lt;/code&gt;. Follow that literally in most HTTP clients and the API answers &lt;strong&gt;HTTP 500&lt;/strong&gt;. The docs are not wrong; your client is helpfully breaking them.&lt;/p&gt;

&lt;p&gt;Here is the trap, plus a second one we reproduced live this morning that will flake your CI if you do not handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Two things will bite you on openFDA and neither is documented where you will look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;+&lt;/code&gt; is not a literal plus.&lt;/strong&gt; openFDA's docs show &lt;code&gt;search=field:value+AND+other:value&lt;/code&gt;, but a URL-encoding HTTP client percent-encodes &lt;code&gt;+&lt;/code&gt; to &lt;code&gt;%2B&lt;/code&gt;, which openFDA parses as part of the term rather than as a separator — and returns a 500. &lt;strong&gt;Join with spaces&lt;/strong&gt; and let your client encode them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;openFDA returns intermittent 500s on cold queries.&lt;/strong&gt; We measured it again this morning: the first request timed out server-side after 31 seconds, and two immediate identical retries returned 200 in ~1.2 seconds each. If your client does not retry 500s, your smoke test is a coin flip.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;+AND+&lt;/code&gt; trap in full
&lt;/h2&gt;

&lt;p&gt;The openFDA query syntax is Lucene-ish. To combine terms, the docs show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://api.fda.gov/drug/enforcement.json?search=classification:"Class+I"+AND+status:"Ongoing"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That URL works when you paste it into a browser address bar, which is why it survives in documentation. It stops working the moment a real HTTP client touches it, because &lt;code&gt;+&lt;/code&gt; is a reserved character in a query string. Most clients — and every sane URL-building library — will percent-encode a literal &lt;code&gt;+&lt;/code&gt; in a parameter value to &lt;code&gt;%2B&lt;/code&gt;. openFDA then sees &lt;code&gt;%2BAND%2B&lt;/code&gt; decoded as &lt;code&gt;+AND+&lt;/code&gt; &lt;em&gt;inside the term&lt;/em&gt;, not as a boolean operator, and the query blows up with a 500 rather than a helpful 400.&lt;/p&gt;

&lt;p&gt;The fix is boring and total: &lt;strong&gt;use spaces&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="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;search&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;classification:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Class I&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; AND status:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ongoing&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;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your client encodes the spaces as &lt;code&gt;%20&lt;/code&gt; (or as &lt;code&gt;+&lt;/code&gt;, correctly, at the parameter level), openFDA parses the boolean, and you get results. We lost a build to this once. It is now the first line of our openFDA notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does openFDA return HTTP 500 randomly?
&lt;/h2&gt;

&lt;p&gt;Because it sometimes just does, and you have to plan for it. This is not a theory — here is a measurement from &lt;strong&gt;2026-08-18&lt;/strong&gt;, three identical requests to the device clearance endpoint, run back to back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;try 1: HTTP 500   146 B   31.09 s
try 2: HTTP 200   21108 B  1.30 s
try 3: HTTP 200   21108 B  1.21 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 500 body is explicit about what happened:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SERVER_ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Check your request and try again"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Request Timeout after 30000ms"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a &lt;em&gt;server-side&lt;/em&gt; 30-second timeout on a cold query, returned to you as a 500. The identical query is then served from warm state in about a second. Note the message: "Check your request and try again" is misleading — there is nothing wrong with the request.&lt;/p&gt;

&lt;p&gt;The consequence for anyone building on this API: &lt;strong&gt;a 500 from openFDA is a retryable condition, not a permanent failure.&lt;/strong&gt; Retry with backoff, at least three attempts. If you treat 5xx as fatal, your pipeline will fail intermittently in a way that is nearly impossible to reproduce on demand, because the second time you check, it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paging ceilings nobody mentions until you hit them
&lt;/h2&gt;

&lt;p&gt;Two hard limits, both of which return errors rather than empty pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;limit&lt;/code&gt; maxes out at &lt;strong&gt;1000&lt;/strong&gt; per request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;skip&lt;/code&gt; maxes out at &lt;strong&gt;25000&lt;/strong&gt;. Past that, openFDA errors instead of returning nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the maximum reachable window for a single query is 26,000 records. If your result set is bigger, you cannot page your way through it — you have to &lt;em&gt;partition the query&lt;/em&gt;, usually by date range, and page inside each partition. Any scraper that silently stops at 25k and reports success is lying to its user; ours logs the ceiling explicitly and tells you to narrow the range.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nullable-schema failure that only shows up in production
&lt;/h2&gt;

&lt;p&gt;One more, because it is the same shape as the retry bug — it hides from your tests.&lt;/p&gt;

&lt;p&gt;openFDA enforcement records routinely omit whole blocks. The &lt;code&gt;openfda&lt;/code&gt; sub-object is absent on a large fraction of records. If you declare an output schema like this:&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;"product_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the first record without that field produces a &lt;code&gt;null&lt;/code&gt;, the &lt;code&gt;null&lt;/code&gt; fails validation, and the write dies &lt;strong&gt;mid-run&lt;/strong&gt; — after you have already emitted rows and charged for them. A curated QA sample with fully-populated records never triggers it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"product_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every optional field in an openFDA-derived schema needs the union type. We fixed this at the scaffolder level so it cannot be reintroduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the openFDA API free?
&lt;/h2&gt;

&lt;p&gt;Yes, and keyless for low volume — which is exactly why it is a good foundation. There is no API key to leak, no OAuth dance, no bot detection, and no HTML to re-parse when someone redesigns a page. The entire difficulty of this target is the four items above: encoding, retries, paging ceilings, and sparse records. All four are solvable once and then permanently solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/fda-recalls-scraper" rel="noopener noreferrer"&gt;FDA Recalls Scraper&lt;/a&gt; queries openFDA's enforcement database and returns one flat row per recall event — recall number, classification, status, recalling firm, product description, reason for recall, distribution pattern, quantity, and initiation and termination dates — as JSON, CSV or Excel. It is pay-per-result at &lt;strong&gt;$2.05 per 1,000 rows&lt;/strong&gt;, so a query that returns nothing costs nothing beyond the start fee.&lt;/p&gt;

&lt;p&gt;We are not going to tell you this API is easy. It returns 500s on healthy queries, it has undocumented ceilings, and its own docs contain a query string that breaks in real HTTP clients. Absorbing all of that — the retries, the partitioning, the schema drift — is our job, not yours.&lt;/p&gt;

&lt;p&gt;If you need the neighbouring regulatory sources, we also run &lt;a href="https://apify.com/DevilScrapes/clinicaltrials-gov-scraper" rel="noopener noreferrer"&gt;ClinicalTrials.gov&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/npi-healthcare-provider-scraper" rel="noopener noreferrer"&gt;NPI healthcare providers&lt;/a&gt; and &lt;a href="https://apify.com/DevilScrapes/professional-license-lookup-scraper" rel="noopener noreferrer"&gt;professional license lookups&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;On openFDA, join with spaces not &lt;code&gt;+AND+&lt;/code&gt;, retry every 500, partition past 25k, and make every optional field nullable. Those four rules are the whole integration.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webscraping</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The day our scraper fleet shipped nothing — and the afternoon it shipped three</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Tue, 18 Aug 2026 04:30:46 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/the-day-our-scraper-fleet-shipped-nothing-and-the-afternoon-it-shipped-three-4jjo</link>
      <guid>https://dev.to/devil_scrapes/the-day-our-scraper-fleet-shipped-nothing-and-the-afternoon-it-shipped-three-4jjo</guid>
      <description>&lt;p&gt;We spent an entire working day last week shipping nothing. Nine scrapers were queued, every piece of release automation worked, and the day ended with zero listings live. That same afternoon, three different scrapers went from empty directory to published in about an hour each, first attempt, no retries.&lt;/p&gt;

&lt;p&gt;The difference was not effort, tooling, or engineering skill. It was what we pointed at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;If your scraping backlog is a list of consumer marketplaces, your throughput is capped by someone else's bot-detection budget, not by your own velocity. &lt;strong&gt;Keyless public JSON APIs — OpenAlex, Crossref, GBIF, openFDA, World Bank, SEC — ship on the first attempt because there is nothing to block.&lt;/strong&gt; We now sort the backlog by target class before we sort it by revenue, and a walled target only enters a build wave with a specific tested plan for its defences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day that shipped nothing
&lt;/h2&gt;

&lt;p&gt;Nine Actors sat in the publish queue. All nine were consumer marketplaces and job boards with commercial anti-bot in front of them. Every one was cloud-QA'd on datacenter proxy &lt;em&gt;and&lt;/em&gt; on residential proxy. All nine failed.&lt;/p&gt;

&lt;p&gt;That is a real result and worth writing down, because "we were blocked" is a useless note. The useful version names the tiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9/9 failed cloud QA — datacenter AND residential, same day, same fixtures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What made it expensive was that the failure was invisible from inside the process. Every stage worked. The specs were fine, the tests were green, the pricing wired up correctly, the publisher refused correctly. The lane was healthy and the output was zero, which is the worst combination to debug because nothing is flashing red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The afternoon that shipped three
&lt;/h2&gt;

&lt;p&gt;We changed one variable: target class. Instead of marketplaces, we took three public research and regulatory APIs that require no key. Scaffold, implement, cloud QA, publish. Roughly an hour each, all three passed cloud QA on the first run.&lt;/p&gt;

&lt;p&gt;There is no clever trick here. A public JSON API has no incentive to fingerprint you, no CAPTCHA, no TLS fingerprint check, no HTML that gets rewritten on Tuesday. The whole class of failure that eats marketplace scrapers simply does not exist. What is left is ordinary engineering: paging, schema, and error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is OpenAlex a good scraping target?
&lt;/h2&gt;

&lt;p&gt;OpenAlex is the open replacement for Microsoft Academic Graph — roughly 250 million scholarly works, with authors, institutions, journals, citation counts and open-access status. It is free, it needs no API key, and it is genuinely well-behaved if you are.&lt;/p&gt;

&lt;p&gt;Two things matter in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use cursor paging, not offset.&lt;/strong&gt; Offset paging on a corpus this size falls apart past the first few thousand rows — the API caps how deep you can page and the cost of a deep offset grows. The cursor is the supported path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.openalex.org/works?per-page=200&amp;amp;cursor=*
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each response hands you &lt;code&gt;meta.next_cursor&lt;/code&gt;; you pass it back until it comes back null. Stateless, stable under concurrent updates, and it does not degrade at depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identify yourself and get the fast lane.&lt;/strong&gt; OpenAlex runs a "polite pool" — include a contact in your &lt;code&gt;User-Agent&lt;/code&gt; or a &lt;code&gt;mailto&lt;/code&gt; parameter and your requests go to a separate, faster, more reliably-served pool. This is the rare case where telling the server who you are makes your scraper &lt;em&gt;better&lt;/em&gt;, not more blockable. We send our public contact URL, never a personal address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that survived the easy target
&lt;/h2&gt;

&lt;p&gt;Keyless does not mean trivial. The bug that cost us the most time on this batch had nothing to do with blocking.&lt;/p&gt;

&lt;p&gt;Apify Actors can declare a dataset schema, and ours declared fields like this:&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;"doi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is wrong, and it is wrong in the worst possible way: it only fails on a &lt;em&gt;sparse&lt;/em&gt; record. Scholarly metadata is extremely sparse — plenty of works have no DOI, no abstract, no ISSN, no funder. The first record missing one of those fields produces a null, the null fails validation, and &lt;code&gt;push_data&lt;/code&gt; dies mid-run. A fully-populated QA sample never triggers it, so the smoke test passes and the customer's real query is what breaks.&lt;/p&gt;

&lt;p&gt;The fix is one character of JSON per field:&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;"doi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&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;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We fixed it at the scaffolder so no future Actor can be born with it. The general lesson: &lt;strong&gt;a QA fixture that is too clean is worse than no fixture&lt;/strong&gt;, because it converts a loud failure into a delayed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your backlog
&lt;/h2&gt;

&lt;p&gt;Sort by class first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyless public APIs&lt;/strong&gt; — ship first-attempt. Government registries, research indexes, open data portals, standards bodies. Low glamour, real buyers, and they do not fight back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain HTML sites with no commercial anti-bot&lt;/strong&gt; — ship with ordinary care.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercially defended marketplaces&lt;/strong&gt; — only with a specific, tested plan for their defences, and a named tier you have actually probed. Never on optimism.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We ran category 3 on optimism for a long time. The scoreboard was honest about it exactly once, on the day it produced a zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scraper actually does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/DevilScrapes/openalex-works-scraper" rel="noopener noreferrer"&gt;OpenAlex Works Scraper&lt;/a&gt; searches the 250M-work catalogue and returns one flat row per work — title, DOI, authors with ORCIDs and affiliations, journal, publication date, citation count and open-access status — as JSON, CSV or Excel. It is pay-per-result at &lt;strong&gt;$2.05 per 1,000 rows&lt;/strong&gt;, so a query that returns nothing costs you nothing beyond the start fee.&lt;/p&gt;

&lt;p&gt;We are not going to pretend that any source stays static forever. APIs deprecate fields, tighten rate limits and reshape their pagination, and when OpenAlex does, absorbing that is our job, not yours — the cursor handling, the retries and the schema drift are on our side of the line.&lt;/p&gt;

&lt;p&gt;If you work the same corpus from other angles, the neighbours are &lt;a href="https://apify.com/DevilScrapes/pubmed-papers-scraper" rel="noopener noreferrer"&gt;Crossref-adjacent research tooling&lt;/a&gt;, &lt;a href="https://apify.com/DevilScrapes/arxiv-papers-scraper" rel="noopener noreferrer"&gt;arXiv&lt;/a&gt; and &lt;a href="https://apify.com/DevilScrapes/eu-cordis-grants" rel="noopener noreferrer"&gt;EU CORDIS grants&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we wrote down
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A backlog sorted only by revenue potential will happily spend a month on targets that cannot ship. Sort by target class first, then by revenue inside each class.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the whole finding. It cost us a zero-release day to learn, and it is the cheapest thing in this post to copy.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Redfin caps at 350, Apple caps at ~50, Glassdoor caps at 3 — none of them are rate limits</title>
      <dc:creator>Devil Scrapes</dc:creator>
      <pubDate>Mon, 17 Aug 2026 11:21:21 +0000</pubDate>
      <link>https://dev.to/devil_scrapes/redfin-caps-at-350-apple-caps-at-50-glassdoor-caps-at-3-none-of-them-are-rate-limits-41a7</link>
      <guid>https://dev.to/devil_scrapes/redfin-caps-at-350-apple-caps-at-50-glassdoor-caps-at-3-none-of-them-are-rate-limits-41a7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; three review/listing platforms, three different reasons you stop getting more data. Redfin's 350-listing cap is a countable server limit, and we bisect around it with price bands. Apple's ~50-review ceiling on its customer-reviews feed is architectural and permanent — page 2 is dead, not blocked, and no amount of retrying brings it back. Glassdoor's ~3-full-reviews-per-page-load ceiling for anonymous visitors isn't a count limit at all; it's how much of the page actually renders before you're not logged in. Treat all three as "a rate limit" and you'll either burn a week chasing a wall that was never there, or under-price a scraper that's doing far more work than the row count suggests.&lt;/p&gt;

&lt;p&gt;Here's each ceiling's real shape, how we found it, and why the fix is different every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧱 Why "just paginate harder" is the wrong first question
&lt;/h2&gt;

&lt;p&gt;The instinct when a scraper stops returning data past some point is to assume a rate limit and add a delay. Sometimes that's right. It wasn't right for any of the three targets below. Each one hit a hard stop that had nothing to do with request pacing — a server-side count cap, a feed that was never built to page past one screen, and a rendering budget for logged-out visitors. Backing off and retrying does nothing for any of them. The fix has to match the &lt;em&gt;shape&lt;/em&gt; of the ceiling, and the three shapes below cover most of what you'll run into scraping review and listing data anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏠 Redfin: a countable ceiling, so we count around it
&lt;/h2&gt;

&lt;p&gt;Every call to Redfin's &lt;code&gt;gis&lt;/code&gt; search endpoint returns at most 350 homes, full stop, no matter what you ask it for. Ask for a dense metro's for-sale inventory and the 351st home just doesn't come back — no error, no truncation flag, nothing that tells you to keep going.&lt;/p&gt;

&lt;p&gt;Because the cap is a count against a &lt;em&gt;filterable&lt;/em&gt; result set, it's defeatable: split the price range into buckets, request each bucket separately, and recurse into any bucket that still comes back at 350 (&lt;code&gt;mpt=99&lt;/code&gt; plus a &lt;code&gt;min_price&lt;/code&gt;/&lt;code&gt;max_price&lt;/code&gt; pair does the splitting). We stop bisecting once a bucket's width drops below a configurable floor ($25,000 by default) so a genuinely thin price band doesn't get sliced forever. Every home gets deduped by &lt;code&gt;property_id&lt;/code&gt; across buckets, because the same listing straddles bucket boundaries more often than you'd expect. Each bucket also fails in isolation — a slow or blocked bucket gets logged and skipped rather than taking the whole search down.&lt;/p&gt;

&lt;p&gt;That's the &lt;a href="https://apify.com/DevilScrapes/redfin-property-listings-scraper" rel="noopener noreferrer"&gt;Redfin Property Listings Scraper&lt;/a&gt;: normalized for-sale/sold/pending listings with MLS id, days-on-market, HOA, exact lat/long, and open-house windows, at $2.52 per 1,000 results. The cap here is annoying but honest — it always tells you the same number, and a countable ceiling is the one shape you can engineer straight through.&lt;/p&gt;

&lt;h2&gt;
  
  
  🍎 App Store reviews: a ceiling that was never a wall to climb
&lt;/h2&gt;

&lt;p&gt;Apple's customer-reviews RSS/JSON feed looks like it should paginate — the URL has room for a page number, and every review-scraping tutorial on the internet assumes &lt;code&gt;page=2&lt;/code&gt; works. During recon on 2026-08-15 we tried every path-segment ordering we could construct for page 2 and higher against that feed. All of them came back dead. Page 1 — roughly 50 reviews — is the entire dataset Apple will hand you per app, forever, and we scoped the code to never even build a &lt;code&gt;page&lt;/code&gt; parameter, because a permanent dead end doesn't deserve a retry loop pretending otherwise.&lt;/p&gt;

&lt;p&gt;That's a different kind of ceiling than Redfin's. There's no bucket to bisect and no query shape that unlocks more. The only honest move is to say so — in the input field description, the README, and the pricing — instead of quietly under-delivering against an implied "as many as you ask for."&lt;/p&gt;

&lt;p&gt;Where this Actor earns its keep is the half of the ceiling that &lt;em&gt;isn't&lt;/em&gt; fixed: Google Play's review RPC does paginate toward whatever cap you set. The &lt;a href="https://apify.com/DevilScrapes/app-store-reviews-aso-scraper" rel="noopener noreferrer"&gt;App Store Reviews &amp;amp; ASO Scraper&lt;/a&gt; fetches both stores for the same app in one run — one unified schema with a &lt;code&gt;store&lt;/code&gt; discriminator — and computes a per-app ASO rollup on top: rating distribution, month-by-month rating trend, review velocity, and top praise/complaint keywords via a deterministic stoplist-frequency count (no sentiment ML, no black box). Every incumbent we found on the Store covers exactly one platform; nobody else stitches Play and Apple into one comparable ASO view. Priced at $1.20–1.50 per 1,000 rows blended across a $0.05 warm-up, $0.0012 per review, and $0.005 per rollup row.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Glassdoor: a ceiling that isn't measured in rows at all
&lt;/h2&gt;

&lt;p&gt;The Glassdoor case is the one that breaks the "it's a rate limit, add a delay" instinct hardest, because the ceiling isn't about how many pages you can request — it's about how much of &lt;em&gt;one&lt;/em&gt; page renders for a visitor who isn't logged in. Anonymous access surfaces roughly three full review entries per page load; the rest of that page's reviews exist in the DOM in truncated or teaser form. You can absolutely request page 20. You'll get a 200 and a page. You will not get twenty pages' worth of full review text out of it.&lt;/p&gt;

&lt;p&gt;Getting onto that page at all is its own problem — Glassdoor's review pages sit behind bot defenses that a plain HTTP request doesn't survive, so the &lt;a href="https://apify.com/DevilScrapes/glassdoor-reviews-scraper" rel="noopener noreferrer"&gt;Glassdoor Reviews Scraper&lt;/a&gt; runs a full browser session: a proven navigation sequence through the same browser context and page object, not a fresh request per URL, with the proxy session pinned for the life of that context. One company failing to resolve or parse never sinks the run — it's logged, skipped, and every other company still completes.&lt;/p&gt;

&lt;p&gt;Because the real throughput is ~3 full reviews per page load, not the page count you asked for, &lt;code&gt;maxReviewsPerCompany&lt;/code&gt; and pricing are built around that reality instead of promising cheap bulk collection we can't actually deliver without a login we refuse to use. That's also why this one sits at the top of our usual range: $10.00 per 1,000 review rows, because each row costs real browser-navigation compute, not a cheap HTTP round-trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧪 The three shapes, side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;What the ceiling actually is&lt;/th&gt;
&lt;th&gt;Can you defeat it?&lt;/th&gt;
&lt;th&gt;The right response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Redfin&lt;/td&gt;
&lt;td&gt;Server-side count cap (350/call) on a filterable set&lt;/td&gt;
&lt;td&gt;Yes — partition the filter (price bisection) and dedup&lt;/td&gt;
&lt;td&gt;Bisect, recurse, dedup by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple App Store feed&lt;/td&gt;
&lt;td&gt;Architectural — page 2+ doesn't exist, verified dead&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Scope it out permanently, state it in docs and pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glassdoor (anonymous)&lt;/td&gt;
&lt;td&gt;Rendering depth per page load, not a count&lt;/td&gt;
&lt;td&gt;No, without a login we won't use&lt;/td&gt;
&lt;td&gt;Price and cap around the real per-page yield&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mistaking any row of that table for either of its neighbors is where scrapers go wrong: bisecting against an architectural ceiling wastes requests on a wall that logs nothing new; retrying against a rendering-depth ceiling just re-fetches the same three reviews with extra latency; and quietly capping a countable ceiling "for safety" leaves real, gettable data on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  ❓ FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I get more than ~50 Apple App Store reviews per app?&lt;/strong&gt;&lt;br&gt;
Not from this feed. Page 2 and beyond returned nothing across every URL shape we tried during recon — it's a permanent, one-page ceiling on Apple's side, not a setting we could raise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Redfin's price-bucket bisection risk getting rate-limited?&lt;/strong&gt;&lt;br&gt;
We retry with exponential backoff on &lt;code&gt;408/429/5xx&lt;/code&gt; and rotate the session on a block, same as every Actor in the fleet — bisection adds more requests per search, not more requests per second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is Glassdoor priced at $10/1,000 when the others sit closer to $1–2.50?&lt;/strong&gt;&lt;br&gt;
Because the real unit of work is a full browser navigation per company, not a cheap API call, and anonymous access only renders a handful of full reviews per page load — the price reflects the compute that actually happens, not a theoretical row count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do any of these need a login or API key?&lt;/strong&gt;&lt;br&gt;
No, for all three. Apple and Google Play are public feeds; Glassdoor and Redfin are scraped from their own public pages, no account, no credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will one bad app or company stop the whole run?&lt;/strong&gt;&lt;br&gt;
No — all three Actors isolate faults per item. A resolution failure or empty result for one entry gets logged and skipped; the rest of the run completes and the run's status message reports exactly what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  📦 The takeaway
&lt;/h2&gt;

&lt;p&gt;"That's all the data you get" means three different things depending on which wall you hit. A countable ceiling gets engineered through. An architectural one gets documented and priced around, not chased. A rendering-depth ceiling gets measured honestly so the price matches the real work, not the row count on the label. We built all three read on the ceiling into the Actor before it ever shipped, so the number on the pricing page is the number you'll actually get — not the number the target's marketing implies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We do the dirty work so your dataset stays clean.&lt;/em&gt; 😈&lt;/p&gt;

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