<?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: Felix Yang</title>
    <description>The latest articles on DEV Community by Felix Yang (@yff124).</description>
    <link>https://dev.to/yff124</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%2F4176649%2F8ddc86ac-ddf4-404f-93a4-4e306d2c9bb2.jpg</url>
      <title>DEV Community: Felix Yang</title>
      <link>https://dev.to/yff124</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yff124"/>
    <language>en</language>
    <item>
      <title>204 of a board's 232 jobs were "updated" in the same second</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Sat, 17 Oct 2026 18:58:55 +0000</pubDate>
      <link>https://dev.to/yff124/204-of-a-boards-232-jobs-were-updated-in-the-same-second-4mpn</link>
      <guid>https://dev.to/yff124/204-of-a-boards-232-jobs-were-updated-in-the-same-second-4mpn</guid>
      <description>&lt;p&gt;I aggregate job boards. One of the features is "posted in the last 24 hours".&lt;/p&gt;

&lt;p&gt;For a week it worked. Then I pulled a full board and asked a simpler question than I usually ask: &lt;em&gt;what does the &lt;code&gt;updated_at&lt;/code&gt; distribution actually look like?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitLab's board, 232 live postings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;distinct &lt;code&gt;updated_at&lt;/code&gt; values&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jobs sharing one single timestamp&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;204&lt;/strong&gt; (&lt;code&gt;2026-10-15T15:24:46-04:00&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;earliest &lt;code&gt;first_published&lt;/code&gt; on the board&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-03-06&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;updated_at&lt;/code&gt; on that same job&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2026-10-15&lt;/code&gt; — &lt;strong&gt;222 days later&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So my "new in the last 24 hours" feed was reporting 204 jobs as fresh. Their real first-published dates span seven months. Every one of them was 222 days old at the most, and one of them was a March posting wearing an October timestamp.&lt;/p&gt;

&lt;p&gt;Nothing errored. The API returned 200, the timestamps parsed, the sort worked. The output was just wrong in the way that survives review — plausible, formatted, and committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timestamp is a column about the record, not about the posting
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;updated_at&lt;/code&gt; is a data-quality field. It answers "when did this row last change in our database", and a board that re-syncs its whole catalogue changes every row at once. That's the 204. It is not a bug on Greenhouse's side; it's a field being asked to answer a question it was never about.&lt;/p&gt;

&lt;p&gt;The tell is the histogram. Twenty-nine distinct values across 232 rows is not a posting pattern. Postings arrive one at a time — a company does not hire 204 people in the same second. A single value covering 88% of the board is a write operation.&lt;/p&gt;

&lt;p&gt;Compare the other two vendors I hit, same question, same day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;Distinct values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt; (&lt;code&gt;/v1/boards/{slug}/jobs&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Ashby&lt;/strong&gt; (&lt;code&gt;/posting-api/job-board/{slug}&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;publishedAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;167 / 814&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;167 / 814&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Lever&lt;/strong&gt; (&lt;code&gt;/v0/postings/{slug}?mode=json&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ashby: 814 postings, 814 distinct timestamps. Lever: 82 and 82. Those two answer "when was this posted" with a per-posting event. Greenhouse answers with a per-row write. Same logical field, opposite reliability — and if you wrote one normaliser and trusted it, you would only be wrong on one third of your sources, which is exactly the amount of wrong that doesn't get noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;Two fields, one local set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DON'T: this is a "board sync" feed, not a "new jobs" feed
&lt;/span&gt;&lt;span class="n"&gt;new_jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;

&lt;span class="c1"&gt;# DO: structural date for recency, id for novelty
&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_seen_ids&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                      &lt;span class="c1"&gt;# persisted between runs
&lt;/span&gt;&lt;span class="n"&gt;first_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;
&lt;span class="n"&gt;new_jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_run&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;last_run_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;save_seen_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;first_published&lt;/code&gt; is the only Greenhouse field that behaves like a posting date. But it still can't tell you &lt;em&gt;newly&lt;/em&gt; new on its own — a job published in March and still open is legitimately in the feed forever. Recency is a property of the posting; novelty is a property of &lt;em&gt;your&lt;/em&gt; observation. Those are two different clocks and they need two different fields.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;first_run&lt;/code&gt; branch matters more than it looks. On a cold start, every posting is unseen, so the difference set is the whole board. Without that branch you either publish 232 "new" jobs on day one, or you silently drop the board and never recover. I hit both.&lt;/p&gt;

&lt;p&gt;This is the same failure as the missing-key trap in &lt;a href="https://dev.to/yff124/every-careers-page-has-a-public-json-api-you-just-never-noticed-4hj4"&gt;the first post of this series&lt;/a&gt; — a field that exists, parses, and returns a value that means something other than what you assumed. &lt;code&gt;null&lt;/code&gt; at least stops you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things that cost more than they look
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;?content=true&lt;/code&gt; is a 22× tax.&lt;/strong&gt; GitLab's board, same 232 jobs either way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;request&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;163,949&lt;/td&gt;
&lt;td&gt;1.0×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/jobs?content=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,646,907&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22.2×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/jobs?content=true&lt;/code&gt;, gzip&lt;/td&gt;
&lt;td&gt;1,180,797&lt;/td&gt;
&lt;td&gt;7.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The job count is identical. You get the same 232 records with a &lt;code&gt;content&lt;/code&gt; key added. Fetch descriptions on the detail endpoint for the jobs you actually keep, not on the listing that exists to be filtered.&lt;/p&gt;

&lt;p&gt;And note the middle column versus the right one: &lt;strong&gt;22× and 7× are the same request.&lt;/strong&gt; If you benchmark bandwidth with &lt;code&gt;curl --compressed&lt;/code&gt; and then reason about your JSON parser's memory, you are off by a factor of three and you will never find it, because both numbers are real and neither is labelled. I have made this mistake in a report; the fix is to write the encoding into the sentence, not the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;limit&lt;/code&gt; can be decorative.&lt;/strong&gt; Remotive accepts &lt;code&gt;?limit=&lt;/code&gt; and ignores it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;limit=3   -&amp;gt; HTTP 200, 173,720 B, job-count 13
limit=50  -&amp;gt; HTTP 200, 173,720 B, job-count 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Byte-identical. Both responses are the same 13 jobs. A &lt;code&gt;limit&lt;/code&gt; parameter that parses, validates, and does nothing is worse than no parameter — you'll build a paging loop on it and silently stop at whatever the server felt like sending. The only way to know a parameter works is to send two values and compare the &lt;em&gt;counts&lt;/em&gt;, not the status codes. Both were 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I write down now
&lt;/h2&gt;

&lt;p&gt;For every timestamp I consume, I check the histogram before I check the value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distinct values ≈ row count&lt;/strong&gt; → it's an event field. Trust it for recency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One value covering most rows&lt;/strong&gt; → it's a write field. It records your vendor's cron, not the world.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That check costs one &lt;code&gt;Counter()&lt;/code&gt; and it is the difference between a feed and a random-number generator. The 204 rows were not lying to me. They were answering the question I should have asked.&lt;/p&gt;

&lt;p&gt;If you'd rather not maintain three normalisers yourself: I run this as a hosted actor — paste a company, slug or careers URL and get one clean dataset back, built from the Greenhouse, Ashby and Lever job-board APIs. &lt;a href="https://apify.com/fei.yang/ats-job-board-aggregator" rel="noopener noreferrer"&gt;ATS Job Board Aggregator&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and verified by hand — every board, count and byte figure above was pulled live on 2026-10-18. The normalisers are in &lt;a href="https://github.com/yff124/apify-actors/blob/main/ats_normalizers.py" rel="noopener noreferrer"&gt;&lt;code&gt;ats_normalizers.py&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
    </item>
    <item>
      <title>Your fetch failed, your code wrote an empty array, and now your tests assert a lie</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Fri, 16 Oct 2026 20:38:09 +0000</pubDate>
      <link>https://dev.to/yff124/your-fetch-failed-your-code-wrote-an-empty-array-and-now-your-tests-assert-a-lie-3ijl</link>
      <guid>https://dev.to/yff124/your-fetch-failed-your-code-wrote-an-empty-array-and-now-your-tests-assert-a-lie-3ijl</guid>
      <description>&lt;p&gt;A scraper of mine reported that a GitHub repository had &lt;strong&gt;zero&lt;/strong&gt; closed pull requests.&lt;/p&gt;

&lt;p&gt;The repository had dozens. It had its first PR closed &lt;strong&gt;21 minutes after it was created&lt;/strong&gt;, three days before the scraper ran.&lt;/p&gt;

&lt;p&gt;Nothing crashed. No test failed. The number was produced, written to disk, committed, and then quoted in a report as evidence. The bug was not that the scraper lied — it is that my pipeline had no vocabulary for &lt;em&gt;"I don't know"&lt;/em&gt;, so it said &lt;code&gt;0&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain of custody of a wrong number
&lt;/h2&gt;

&lt;p&gt;Here is the whole failure, step by step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A timeout that didn't look like a timeout.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fetch was a &lt;code&gt;curl&lt;/code&gt; call with a 25-second ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 25 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.github.com/repos/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;/pulls?state=closed&amp;amp;per_page=100"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A large repo returned ~606 KB. The body did not finish in 25 seconds, so &lt;code&gt;curl&lt;/code&gt; killed the transfer mid-flight. &lt;code&gt;curl&lt;/code&gt; still exited having written &lt;em&gt;something&lt;/em&gt; to stdout, and — this is the part that matters — &lt;code&gt;stdout&lt;/code&gt; contained the first N bytes of a perfectly valid JSON array, cut off in the middle of a string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The parse error was swallowed.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;- the entire bug lives on this line
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;except Exception&lt;/code&gt;. It was written to be defensive and it is exactly what turned a truncation into a measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The empty list was written to disk as a result.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Downstream code received &lt;code&gt;[]&lt;/code&gt;, counted it, and stored &lt;code&gt;closed_prs: 0&lt;/code&gt;. A reader of that file cannot distinguish it from a repo that genuinely has no closed PRs. The snapshot format had one slot where there should have been two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The empty list was promoted to a test fixture.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the expensive part. A regression fixture was created to cover the case "we found no evidence, so we must answer &lt;code&gt;UNKNOWN&lt;/code&gt;". A real repository was picked as the realistic example, and the recorded value was &lt;code&gt;closed_prs: 0&lt;/code&gt; — read straight from the poisoned snapshot. From that commit onward, the test suite &lt;strong&gt;asserted a false statement about a third party&lt;/strong&gt;, forever, and passing tests meant the bug was invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The only reason it was caught&lt;/strong&gt; was that the repo was small enough to enumerate every PR with its own timestamp. On a repo with 900 PRs there would have been no way to notice, and the false fact would still be in the fixture today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two variations of the same bug
&lt;/h2&gt;

&lt;p&gt;Once I started looking, the same shape showed up elsewhere in the same codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variant A — a successful HTTP call that means "you are not authenticated".&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/runs"&lt;/span&gt;
&lt;span class="c"&gt;# HTTP 401&lt;/span&gt;
&lt;span class="c"&gt;# body: {"error": {"type": "unauthorized", "message": "Invalid token"}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check was: &lt;em&gt;did &lt;code&gt;curl&lt;/code&gt; succeed, and does the body parse as JSON?&lt;/em&gt; Both true. Therefore &lt;code&gt;runs = []&lt;/code&gt;. The dashboard reported &lt;strong&gt;zero runs&lt;/strong&gt; for weeks. The token had expired on day one.&lt;/p&gt;

&lt;p&gt;An auth failure had been silently converted into a zero — and zero is a plausible number for runs, which is why nobody questioned it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variant B — the timeout that leaves no trace at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--max-time&lt;/code&gt; bounds the &lt;em&gt;whole&lt;/em&gt; transfer. It does not bound the connect phase. Measured hang before the failure surfaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ... 268,008 ms later ...&lt;/span&gt;
&lt;span class="c"&gt;# curl: (28) Operation timed out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four and a half minutes, with a 30-second timeout on the command line. If your retry logic lives above this call, your retry logic never ran. &lt;code&gt;--connect-timeout&lt;/code&gt; is a separate, non-negotiable flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 8 &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a hung first call was enough to abort the entire scan and discard three windows of already-collected data, because one unguarded call sat outside every &lt;code&gt;try&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: three states at every boundary
&lt;/h2&gt;

&lt;p&gt;The fix is not "add more error handling". It is to make &lt;code&gt;EMPTY&lt;/code&gt; and &lt;code&gt;ERROR&lt;/code&gt; different types that cannot be compared or coerced into each other, so that the compiler — or at least the first reader — sees the difference.&lt;/p&gt;

&lt;p&gt;I now return a small tri-state from every fetch, and nothing downstream sees a bare list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ERROR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&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;empty&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;          &lt;span class="c1"&gt;# OK | EMPTY | ERROR
&lt;/span&gt;    &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;
    &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;     &lt;span class="c1"&gt;# why it failed; never empty when status == ERROR
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require&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="k"&gt;if&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;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OK&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;fetch &lt;/span&gt;&lt;span class="si"&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;status&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&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="k"&gt;return&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;items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four properties are doing the work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;ERROR&lt;/code&gt; can never be downgraded.&lt;/strong&gt; A helper that returns &lt;code&gt;[]&lt;/code&gt; on failure is not defensive, it is a lie generator. If a caller wants to continue, it must opt in explicitly, and the failure must survive into the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;EMPTY&lt;/code&gt; must be earned.&lt;/strong&gt; An empty list is only a fact when the transport completed &lt;em&gt;and&lt;/em&gt; the parser ran &lt;em&gt;and&lt;/em&gt; the shape was checked. Anything else is &lt;code&gt;ERROR&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl&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;-sS&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;-w&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;%{http_code}&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;--connect-timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl rc=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# read the status, never infer it
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# truncation lands here
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&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="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;data&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected shape: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is &lt;em&gt;not&lt;/em&gt; in that function: no bare &lt;code&gt;except&lt;/code&gt;, and no default value that is also a legitimate result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Snapshot writers must refuse to write on failure.&lt;/strong&gt; A partial or empty write is worse than no write, because the next run diffs against it and reports fabricated change:&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;save_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;windows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;windows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# nothing succeeded -&amp;gt; write nothing at all
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no window fetched: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;err&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="c1"&gt;# partial -&amp;gt; write, but name what is missing
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WARNING: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; window(s) missing: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule underneath: &lt;strong&gt;the file on disk must never be indistinguishable from a successful run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Errors must carry their own diagnosis.&lt;/strong&gt; &lt;code&gt;detail&lt;/code&gt; is why the failure is debuggable six weeks later without re-running anything. &lt;code&gt;"fetch error: 401"&lt;/code&gt; is a fact; &lt;code&gt;"fetch error"&lt;/code&gt; is a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the failure path without lying to yourself
&lt;/h2&gt;

&lt;p&gt;Two things went wrong in how this code was tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the value that can actually be wrong.&lt;/strong&gt; The original test asserted the &lt;em&gt;number of matches&lt;/em&gt; found by the parser. The broken code produced the same number of matches — it was wrong about &lt;em&gt;which row&lt;/em&gt; and &lt;em&gt;what value&lt;/em&gt;, not about &lt;em&gt;how many&lt;/em&gt;. Old code and new code both passed. If a test would pass with the bug present, it is not testing the bug. Assert the field that moves:&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;assert&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;]&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;2026-10-15T20:21:49Z&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;&lt;strong&gt;Use orthogonal single-point failures, not just total failure.&lt;/strong&gt; "Everything fails" is the easy case and it exercises the least code. Run the pipeline twice: once with every fetch failing (expect: no file written, non-zero exit), and once with exactly one named window failing while the others succeed (expect: file written, missing window named, exit 0). The second run is the one that catches the swallowed exception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never let captured data masquerade as a synthetic fixture.&lt;/strong&gt; A fixture harvested from a live third party is a claim about the world, and the world changes. If you keep one, store the command, the timestamp, and the exit status alongside it — or replace it with a hand-written fixture and record why. A fixture that asserts a durable fact about a mutable external system is a scheduled lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisation
&lt;/h2&gt;

&lt;p&gt;Every one of these bugs is the same bug: &lt;strong&gt;a value that means "I don't know" was given the same representation as a value that means "there is nothing".&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is worth hunting for on purpose, because the symptoms are quiet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A function with &lt;code&gt;except Exception: return []&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A pipeline that checks &lt;code&gt;if response.ok&lt;/code&gt; but never &lt;code&gt;response.status_code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Any retry loop wrapping a call whose timeout doesn't cover connecting.&lt;/li&gt;
&lt;li&gt;Any snapshot, cache, or fixture file whose contents are valid in both the "ran and found nothing" and "never ran" cases.&lt;/li&gt;
&lt;li&gt;Any test that asserts a count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these raise. They all produce numbers. And a pipeline that cannot say "I don't know" will say &lt;code&gt;0&lt;/code&gt; instead, which is a much more expensive answer.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Your fake Chrome User-Agent is why that site returns 403</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:32:32 +0000</pubDate>
      <link>https://dev.to/yff124/your-fake-chrome-user-agent-is-why-that-site-returns-403-2l1i</link>
      <guid>https://dev.to/yff124/your-fake-chrome-user-agent-is-why-that-site-returns-403-2l1i</guid>
      <description>&lt;p&gt;I spent twenty minutes convinced a site had gone offline.&lt;/p&gt;

&lt;p&gt;It hadn't. I was the one who broke it — by putting a Chrome User-Agent on a &lt;code&gt;curl&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;Here is the original measurement. Same machine, same connection, same few seconds, one URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;UA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://alistapart.com/about/contribute/
&lt;span class="c"&gt;# 403&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://alistapart.com/about/contribute/
&lt;span class="c"&gt;# 200&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; https://alistapart.com/about/contribute/
&lt;span class="c"&gt;# 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;fake&lt;/strong&gt; browser is the one that gets blocked. The honest &lt;code&gt;curl&lt;/code&gt; is the one that gets the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 403 is not a firewall error, it's a challenge page
&lt;/h2&gt;

&lt;p&gt;The body gives it away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Checking your browser...&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/__challenge/probes.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a JS challenge. The site is not refusing to serve the document — it is refusing to serve it &lt;em&gt;to a client that claims to be Chrome but cannot run the JavaScript it just handed you&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is the whole point. A request that says &lt;code&gt;Chrome/126&lt;/code&gt; and then presents no browser behaviour is a &lt;strong&gt;more&lt;/strong&gt; suspicious artifact than a request that says &lt;code&gt;curl&lt;/code&gt;. It is self-contradictory, and inconsistency is cheap to detect. Being honest costs you nothing here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inverse case is real too
&lt;/h2&gt;

&lt;p&gt;An empty User-Agent fails on the same logic, from the other direction. Requesting &lt;code&gt;https://dev.to/&lt;/code&gt; with no UA at all:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Varnish&lt;/span&gt;
&lt;span class="na"&gt;content-length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero-byte body, no challenge, just a Varnish-layer rejection. "Claims to be nothing" is also easy to classify.&lt;/p&gt;

&lt;p&gt;So there are two failure directions, and they look identical if you only look at the status code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you send&lt;/th&gt;
&lt;th&gt;How it reads to bot mitigation&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Chrome/126&lt;/code&gt; with a non-browser client&lt;/td&gt;
&lt;td&gt;contradictory&lt;/td&gt;
&lt;td&gt;challenge / 403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no User-Agent at all&lt;/td&gt;
&lt;td&gt;anonymous, unclassifiable&lt;/td&gt;
&lt;td&gt;rejection / 403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an honest client UA (&lt;code&gt;curl/8.x&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;internally consistent&lt;/td&gt;
&lt;td&gt;the document&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The control experiment that should be in every scraper repo
&lt;/h2&gt;

&lt;p&gt;One request tells you nothing about a site. It tells you about &lt;em&gt;your request&lt;/em&gt;. So run three, and log which one produced which code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;test_url &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;ua&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-46s chrome=%s default=%s empty=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 25 &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 25 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 25 &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

test_url https://alistapart.com/about/contribute/
test_url https://www.smashingmagazine.com/contact/
test_url https://www.infoq.com/
test_url https://blog.logrocket.com/
test_url https://dev.to/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run just now, that prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alistapart.com/about/contribute/        chrome=403  default=200  empty=200
smashingmagazine.com/contact/           chrome=200  default=200  empty=200
infoq.com/                              chrome=405  default=405  empty=405
blog.logrocket.com/                     chrome=200  default=200  empty=200
dev.to/                                 chrome=200  default=200  empty=403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that table carefully, because it is doing more work than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One URL out of five is UA-sensitive&lt;/strong&gt; (ALA). If I had tested only that one, I would have concluded "UA spoofing is broken everywhere". If I had tested only the other four, I would have concluded "UA spoofing is fine". Both conclusions would have been wrong, and the fix would have been wrong in opposite ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;405&lt;/code&gt; is not a UA problem at all.&lt;/strong&gt; InfoQ returns it identically to all three clients. That is a method/policy answer, and no header change will move it. Chasing it with UA rotation is pure waste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The direction of the effect is not consistent.&lt;/strong&gt; Chrome-UA fails on ALA, empty-UA fails on dev.to. There is no "best UA" to hardcode. There is only "check, per site, per request shape".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two things this is &lt;em&gt;not&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is not a transport failure.&lt;/strong&gt; Separately from all of the above, some hosts are simply unreachable from a given network. &lt;code&gt;https://news.ycombinator.com/&lt;/code&gt; returns &lt;code&gt;000&lt;/code&gt; — curl's "no HTTP response at all", in this case a TLS-level refusal on my egress. All three UA variants returned &lt;code&gt;000&lt;/code&gt;. If you see the same failure across every UA, you are not debugging a header. Stop editing the header.&lt;/p&gt;

&lt;p&gt;The distinction matters because the two failure modes have opposite fixes: a &lt;code&gt;000&lt;/code&gt; is a network/egress problem, a &lt;code&gt;403&lt;/code&gt; may be a client-fingerprint problem, and a &lt;code&gt;405&lt;/code&gt; is neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a licence to bypass anything.&lt;/strong&gt; If a site hands you a challenge page, that is the site answering your request. A scraper that solves the challenge to get around a documented "no automated access" policy is a different activity from a scraper that notices it is sending a self-contradictory request. The three-UA control tells you &lt;em&gt;whether your own client is the problem&lt;/em&gt;. It is a diagnostic, not a bypass — and the honest answer is sometimes "this site does not want a script here, go use their API or their form".&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I ended up with
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A &lt;code&gt;403&lt;/code&gt; is two different bugs wearing the same status code. Before you write "site blocks scrapers" in a README, re-run the request with the default UA and with an empty one. Record all three codes. Only the &lt;em&gt;pattern&lt;/em&gt; is evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Twice now, on this same codebase, the two-UA retest has overturned a conclusion I had already written down: once when a failed request got filed as "the endpoint is gone", and once when it got filed as "the submission form no longer accepts email". Both times the site was fine and my client was lying about itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The checker behind the table above is a few lines of shell — I run the three variants in a wrapper that prints one row per URL, because a status code without its control group is not a measurement. If you maintain scrapers or an uptime check, the cheapest upgrade available to you is probably the second and third request.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and verified by hand — all responses above were pulled live on 2026-10-15 from a single machine and connection.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>debugging</category>
      <category>curl</category>
      <category>api</category>
    </item>
    <item>
      <title>Every careers page has a public JSON API — you just never noticed</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:10:14 +0000</pubDate>
      <link>https://dev.to/yff124/every-careers-page-has-a-public-json-api-you-just-never-noticed-4hj4</link>
      <guid>https://dev.to/yff124/every-careers-page-has-a-public-json-api-you-just-never-noticed-4hj4</guid>
      <description>&lt;p&gt;Most people who need job-posting data reach for a headless browser. They point Playwright at a careers page, hope the React app has hydrated, and write selectors that will break the next time marketing ships a redesign.&lt;/p&gt;

&lt;p&gt;You usually don't need any of that.&lt;/p&gt;

&lt;p&gt;The three ATS platforms that most startups use — &lt;strong&gt;Greenhouse, Ashby and Lever&lt;/strong&gt; — already serve every job board as plain JSON. They have to. That's how the "embed our jobs on your own website" widget works.&lt;/p&gt;

&lt;p&gt;These endpoints are public. No login, no API key, no browser. Here are all three, with real numbers I pulled while writing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three endpoints
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Greenhouse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://boards-api.greenhouse.io/v1/boards/{slug}/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"jobs": [...]}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ashby&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.ashbyhq.com/posting-api/job-board/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"jobs": [...]}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lever&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.lever.co/v0/postings/{slug}?mode=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bare &lt;code&gt;[...]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of them wrap the array; Lever returns a naked one. That alone will make you write three normalizers, so plan for it.&lt;/p&gt;

&lt;p&gt;Live counts, pulled on 2026-10-12:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Greenhouse  stripe    → 727 jobs
Greenhouse  gitlab    → 221 jobs
Ashby       openai    → 811 jobs
Ashby       ramp      → 165 jobs
Lever       leverdemo →  10 jobs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 1,934 postings from five requests. No browser, no proxies, well under a second each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working out which ATS a company uses
&lt;/h2&gt;

&lt;p&gt;Don't guess — read the careers URL. All three vendors use predictable hosts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;job-boards.greenhouse.io/stripe   → greenhouse, slug "stripe"
boards.greenhouse.io/gitlab       → greenhouse, slug "gitlab"
jobs.ashbyhq.com/openai           → ashby,       slug "openai"
jobs.lever.co/leverdemo           → lever,       slug "leverdemo"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slug is whatever sits between the host and the next slash. On a company's own domain the path is usually similar — &lt;code&gt;/careers&lt;/code&gt; redirects, or the embed URL leaks the slug in a query parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest thing that works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stripe
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://boards-api.greenhouse.io/v1/boards/&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;/jobs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.jobs[] | [.title, .location.name, .absolute_url] | @tsv'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in Python, normalizing all three into one shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;greenhouse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://boards-api.greenhouse.io/v1/boards/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;absolute_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updatedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;updated_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&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;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ashby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ashbyhq.com/posting-api/job-board/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&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;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updatedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publishedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&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;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lever&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.lever.co/v0/postings/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?mode=json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# bare array
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hostedUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&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;categories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="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;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updatedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;createdAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The traps nobody mentions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The slug is not the company name.&lt;/strong&gt; &lt;code&gt;gitlab&lt;/code&gt; works. So does a lot of near-misses that don't — which is why you need to distinguish "wrong slug" from "no openings right now". &lt;strong&gt;A board that exists but has zero jobs still returns a valid empty result.&lt;/strong&gt; Test for a 200 with an empty array, not a 404. Otherwise you'll spend an afternoon renaming a company that was never hiring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamps are three different formats.&lt;/strong&gt; Greenhouse gives you ISO-8601 strings, Ashby gives ISO-8601, Lever gives epoch milliseconds as an integer. If you sort a mixed array without normalizing, you get silent nonsense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Descriptions can blow up your payload.&lt;/strong&gt; Greenhouse's &lt;code&gt;?content=true&lt;/code&gt; and Ashby's &lt;code&gt;?includeCompensation=true&lt;/code&gt; pull the full HTML job description — routinely 10–20 KB per posting. On a board with 800 openings that's tens of megabytes for data most pipelines throw away. Fetch titles first, then enrich only the rows you actually need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be a good citizen.&lt;/strong&gt; These are public endpoints, but they're not yours. Space your requests, don't run a hundred boards in parallel, and cache. I use a 250 ms delay between boards and four-way concurrency, which is enough to feel instant and rude to nobody.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the terms before you redistribute.&lt;/strong&gt; The endpoints are public; the postings aren't necessarily yours to republish. Personal data and contact details inside postings deserve particular care, especially if you're in a jurisdiction with data-protection law.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather not maintain this
&lt;/h2&gt;

&lt;p&gt;All of the above is maybe 120 lines of code. I wrote it out and put it here — standard library only, no dependencies, MIT:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/yff124/apify-actors/blob/main/ats_normalizers.py" rel="noopener noreferrer"&gt;github.com/yff124/apify-actors/blob/main/ats_normalizers.py&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 ats_normalizers.py stripe                           &lt;span class="c"&gt;# bare slug, auto-detects the provider&lt;/span&gt;
python3 ats_normalizers.py https://jobs.ashbyhq.com/openai  &lt;span class="c"&gt;# or hand it a careers URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It accepts a company name, an explicit &lt;code&gt;provider:slug&lt;/code&gt;, or a full careers URL, auto-detects which of the three platforms answers, and returns one schema — the same three public endpoints described above, with the slug guessing, the timestamp normalizing and the politeness handled for you.&lt;/p&gt;

&lt;p&gt;If you'd rather not run anything locally, the same thing is also packaged as a hosted Actor — paste a company or a careers URL, it runs in the cloud and hands you a dataset: &lt;strong&gt;&lt;a href="https://apify.com/fei.yang/ats-job-board-aggregator" rel="noopener noreferrer"&gt;ATS Job Board Aggregator&lt;/a&gt;&lt;/strong&gt;. It covers Workday too, which needs a separate endpoint and a paging quirk the script above leaves out.&lt;/p&gt;

&lt;p&gt;Run 5 companies or 500. If you only ever need one company, the curl command at the top of this post is genuinely all you need — I'd rather you knew how it works than treated it as magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update — 2026-10-16
&lt;/h2&gt;

&lt;p&gt;A reader flagged that this post understated one trap, and it's worth putting real numbers on it, because it cost us a bug.&lt;/p&gt;

&lt;p&gt;Greenhouse doesn't return descriptions "if you ask for them, and they're large". It returns &lt;strong&gt;no &lt;code&gt;content&lt;/code&gt; key at all&lt;/strong&gt; unless you pass &lt;code&gt;?content=true&lt;/code&gt;. Same board, same minute, &lt;code&gt;Accept-Encoding: identity&lt;/code&gt; so these are actual payload bytes — 725 jobs in both cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;request&lt;/th&gt;
&lt;th&gt;payload&lt;/th&gt;
&lt;th&gt;keys on &lt;code&gt;jobs[0]&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;content&lt;/code&gt; key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;452,886 B&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/jobs?content=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,646,271 B&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A missing key fails differently from a &lt;code&gt;null&lt;/code&gt; one. Read it with &lt;code&gt;j.get("content")&lt;/code&gt; and you get a silent nothing; validate it against a schema and you get a hard failure — and &lt;strong&gt;only on Greenhouse&lt;/strong&gt;, because Ashby and Lever return content unconditionally. Those two pass, and passing is exactly what hides the problem.&lt;/p&gt;

&lt;p&gt;Two more things that came out of chasing it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;?content=false&lt;/code&gt; is observably identical to sending no parameter&lt;/strong&gt; (38,229 B vs 38,103 B compressed, same 14 keys). We now pass the flag explicitly in both directions instead of trusting the default — "the vendor changed the default" is not a failure you can see coming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripping tags is not enough.&lt;/strong&gt; Decode entities first, then drop &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;, then the remaining tags, then collapse whitespace. Truncating at 20k characters per job is what keeps a 725-job board storable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the mirror image of the slug trap above, stated as a rule: &lt;strong&gt;404 = wrong board; 200 with &lt;code&gt;[]&lt;/code&gt; = right board with nobody hiring.&lt;/strong&gt; All three vendors hand you a real body to tell them apart — Greenhouse &lt;code&gt;{"status":404,...}&lt;/code&gt;, Ashby &lt;code&gt;Document not found&lt;/code&gt;, Lever a plain &lt;code&gt;Not found&lt;/code&gt; — so check the status before you parse, and you'll never spend an afternoon renaming a company that was never hiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Greenhouse branch, after all of this, is in &lt;a href="https://github.com/yff124/apify-actors/blob/main/ats_normalizers.py" rel="noopener noreferrer"&gt;&lt;code&gt;ats_normalizers.py&lt;/code&gt;&lt;/a&gt;.
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and verified by hand — every endpoint and job count in this post was pulled live on 2026-10-12.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
    </item>
    <item>
      <title>CJK line breaking in Canvas, and what the browser will not do for you</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:42:47 +0000</pubDate>
      <link>https://dev.to/yff124/cjk-line-breaking-in-canvas-and-what-the-browser-will-not-do-for-you-38id</link>
      <guid>https://dev.to/yff124/cjk-line-breaking-in-canvas-and-what-the-browser-will-not-do-for-you-38id</guid>
      <description>&lt;p&gt;CSS has been quietly good at CJK line breaking for years. &lt;code&gt;word-break: normal&lt;/code&gt; plus &lt;code&gt;line-break: strict&lt;/code&gt; plus the browser's default break-opportunity table gets you typographically sane paragraphs without writing a line of logic.&lt;/p&gt;

&lt;p&gt;Canvas does not. &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; gives you &lt;code&gt;measureText()&lt;/code&gt; — a width per string — and nothing else. If you build anything that composes text into an image (a share card, a certificate, a dynamic cover, a mini-program poster), you are writing your own line breaker. And the naive one is wrong in a way that is easy not to notice if you do not read Chinese.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive implementation, and exactly how it fails
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accumulate characters until measureText(line + ch).width &amp;gt; maxWidth
then push the line and start a new one
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Latin text this is roughly acceptable, because the breaks almost always land near spaces. For CJK there are no spaces, so the break lands wherever the width runs out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;本产品支持批量导出
图片并自动压缩
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is fine by luck. Now shift the box 20px:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;本产品支持批量导
出图片并自动压缩
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;导出&lt;/code&gt; has been split. A Chinese reader does not read this as a slightly awkward break, they read it as a typo. The same code, two different results, and only one of them is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules that fix most of it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Prefer a break at punctuation.&lt;/strong&gt; Before accepting a width-driven break, scan back up to ~12 characters for the nearest closing punctuation — &lt;code&gt;，。、：；！？）」』】—…&lt;/code&gt; — and break there instead. This single rule accounts for the majority of natural break points in running prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Keep numbers and their units together.&lt;/strong&gt; If the break would leave &lt;code&gt;20&lt;/code&gt; at the end of one line and &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;项&lt;/code&gt; at the start of the next, pull the number down. Readers parse &lt;code&gt;20&lt;/code&gt; / &lt;code&gt;+ 项&lt;/code&gt; as two separate values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never start a line with closing punctuation.&lt;/strong&gt; A full stop, comma, or closing bracket at the start of a line is the single most visible CJK typographic error, and it is also the easiest to check for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLOSERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;，。、：；！？）」』】—…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPENERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;（「『【&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;breakBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;indent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// try punctuation within `indent` characters, walking backwards&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&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="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;indent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLOSERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;k&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="c1"&gt;// break AFTER the closer&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// fall back: never leave a closer stranded at line start&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLOSERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&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;Two implementation details that are easy to get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure with the same font object you draw with.&lt;/strong&gt; &lt;code&gt;ctx.measureText()&lt;/code&gt; after changing &lt;code&gt;ctx.font&lt;/code&gt; reflects the new font. Cache the width of each character once per (font, size) pair; measuring per character per frame is the usual reason a text layout janks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let CJK characters wrap individually but keep Latin words intact.&lt;/strong&gt; A run of &lt;code&gt;[A-Za-z0-9]+&lt;/code&gt; should be treated as one indivisible unit. Mixing the two scripts in one paragraph is the normal case in Chinese product copy, and it is where most homegrown wrappers fall apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you can, do not write this at all
&lt;/h2&gt;

&lt;p&gt;Modern browsers ship &lt;code&gt;Intl.Segmenter&lt;/code&gt; with &lt;code&gt;granularity: "word"&lt;/code&gt;, which gives you the same break-opportunity data the CSS engine uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zh-Hans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;word&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;本产品支持批量导出图片并自动压缩&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Support is now broad enough to use directly, with a character-by-character fallback. If you are on the web and only need to lay out HTML, prefer CSS and stop reading. Canvas is the case where you have no choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that only shows up in production
&lt;/h2&gt;

&lt;p&gt;Canvas fails differently from Python: there is no missing-glyph exception, silently, the same way. If a font fallback chain resolves to a face without the needed glyph, you get tofu boxes or blanks inside your drawn text. In a headless renderer (server-side, or a mini-program's offscreen canvas) you often do not see it until a user screenshots the result.&lt;/p&gt;

&lt;p&gt;So after layout, run a cheap audit over the union of every character you intend to draw, and assert the chosen font actually covers it. Which characters break is always domain-specific — the rare surname on a certificate, the technical term on a data card — so audit your real strings, not lorem ipsum.&lt;/p&gt;




&lt;p&gt;The rule set above, a wrapped-&lt;code&gt;measureText&lt;/code&gt; layout pass, and the fallback chain is roughly what &lt;a href="https://github.com/yff124/cjk-text-render" rel="noopener noreferrer"&gt;cjk-text-render&lt;/a&gt; does on the Python side. Writing the browser version is an afternoon; getting the break rules right is the part people skip.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
      <category>typography</category>
    </item>
    <item>
      <title>Your CJK text is silently broken, and Pillow will not tell you</title>
      <dc:creator>Felix Yang</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:42:20 +0000</pubDate>
      <link>https://dev.to/yff124/your-cjk-text-is-silently-broken-and-pillow-will-not-tell-you-4k4d</link>
      <guid>https://dev.to/yff124/your-cjk-text-is-silently-broken-and-pillow-will-not-tell-you-4k4d</guid>
      <description>&lt;p&gt;If you generate images with Chinese, Japanese, or Korean text in Python, there is a category of bug you have almost certainly shipped: &lt;strong&gt;the text rendered, nothing threw, and the output is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "wrong" as in a stack trace. Wrong as in a user looks at the poster and says "something feels off" without being able to name it. Or worse: a character is simply &lt;em&gt;missing&lt;/em&gt; from the image and nobody notices until print.&lt;/p&gt;

&lt;p&gt;I collected the three that bite hardest while building &lt;a href="https://github.com/yff124/cjk-text-render" rel="noopener noreferrer"&gt;cjk-text-render&lt;/a&gt;, a single-file helper for drawing CJK text with Pillow. All three are silent by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: &lt;code&gt;PingFang.ttc&lt;/code&gt; index 0 is not "Regular"
&lt;/h2&gt;

&lt;p&gt;On macOS, &lt;code&gt;/System/Library/Fonts/PingFang.ttc&lt;/code&gt; is a TrueType Collection. The order of faces inside it is &lt;strong&gt;not&lt;/strong&gt; weight order. It is region order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;index&lt;/th&gt;
&lt;th&gt;face&lt;/th&gt;
&lt;th&gt;notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PingFang &lt;strong&gt;HK&lt;/strong&gt; Regular&lt;/td&gt;
&lt;td&gt;Hong Kong Traditional, the one people accidentally use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;PingFang TC Regular&lt;/td&gt;
&lt;td&gt;Taiwan Traditional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PingFang &lt;strong&gt;SC&lt;/strong&gt; Regular&lt;/td&gt;
&lt;td&gt;Simplified Chinese, the one you want&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 / 4 / &lt;strong&gt;5&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;HK / TC / &lt;strong&gt;SC&lt;/strong&gt; Medium&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 / 7 / &lt;strong&gt;8&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;HK / TC / &lt;strong&gt;SC&lt;/strong&gt; Semibold&lt;/td&gt;
&lt;td&gt;use 8 for bold Simplified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 / 10 / 11&lt;/td&gt;
&lt;td&gt;HK / TC / SC Light&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because &lt;code&gt;index&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt;, the idiomatic-looking call is the wrong 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="c1"&gt;# WRONG: renders everything in Hong Kong Traditional glyph forms
&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/System/Library/Fonts/PingFang.ttc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# RIGHT: Simplified Chinese Regular
&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/System/Library/Fonts/PingFang.ttc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things go wrong when you use index 0. First, every character is drawn in the Hong Kong Traditional glyph variant. Simplified Chinese and Traditional Chinese share a large number of codepoints but not their shapes, so you get characters that are &lt;em&gt;recognisably the wrong form&lt;/em&gt; while still being the right codepoint. Second, and much nastier: some Simplified characters are &lt;strong&gt;absent&lt;/strong&gt; from the HK face. &lt;code&gt;稳&lt;/code&gt; is a common example. Pillow draws a blank space. No exception, no warning, no log line.&lt;/p&gt;

&lt;p&gt;If you want to check what you actually loaded, inspect the face rather than trusting the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/System/Library/Fonts/PingFang.ttc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getname&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; ('PingFang SC', 'Regular')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Print &lt;code&gt;getname()&lt;/code&gt; in CI. It is a one-line assertion that catches an entire class of platform-dependent regressions before they reach a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: missing glyphs are drawn as blank, not raised
&lt;/h2&gt;

&lt;p&gt;This is the one that should be an exception and is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NotoSansCJK-Regular.ttc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;文字と漢字の混在&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;font&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fill&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;black&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;If the loaded face does not contain one of those characters, Pillow does not raise &lt;code&gt;KeyError&lt;/code&gt;, does not log, and does not fall back to another face. It draws nothing for that character. You get a gap in the middle of a word.&lt;/p&gt;

&lt;p&gt;The fix is an explicit audit before you render. Pillow exposes what you need through &lt;code&gt;font.getmask&lt;/code&gt;, but the ergonomic version is a helper that raises on the first missing codepoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cjk_render&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;audit&lt;/span&gt;

&lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all the text you are about to draw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# raises RuntimeError on any missing glyph
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design decision here matters: &lt;strong&gt;make it crash.&lt;/strong&gt; A script that explodes at build time is strictly better than a delivered image with a hole in it. The failure mode you cannot afford is the one that ships.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;audit()&lt;/code&gt; on your real strings, not on sample text. The characters that go missing are always the domain-specific ones — a rare surname on a certificate, a technical term on a data card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: line-breaking CJK by width instead of by punctuation
&lt;/h2&gt;

&lt;p&gt;CJK has no spaces, so the obvious wrapping implementation — accumulate characters until the line exceeds &lt;code&gt;max_width&lt;/code&gt;, then break — produces this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;知道你在打
杂
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word &lt;code&gt;打字&lt;/code&gt; has been cut in half. Readers notice immediately. Latin line-breaking intuition simply does not transfer.&lt;/p&gt;

&lt;p&gt;There are three rules worth encoding:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefer punctuation.&lt;/strong&gt; Look back up to ~12 characters from the overflow point for the nearest of &lt;code&gt;，。、：；！？）」』】—…&lt;/code&gt; and break there if one exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never split numeric units.&lt;/strong&gt; If breaking would put &lt;code&gt;20&lt;/code&gt; on one line and &lt;code&gt;+&lt;/code&gt; on the next, the reader parses two numbers instead of one quantity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No orphaned closing punctuation.&lt;/strong&gt; A full stop or comma must never begin a line.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cjk_render&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;wrap&lt;/span&gt;

&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;一段很长的中文正文……&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_w&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A short list of things that are not traps but will bite anyway
&lt;/h2&gt;

&lt;p&gt;Once the three silent failures are handled, a few loud-but-annoying ones remain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Save PNG, not JPEG.&lt;/strong&gt; Dense CJK strokes at small sizes pick up chromatic noise from JPEG compression. If you must use JPEG, keep &lt;code&gt;quality &amp;gt;= 95&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-fit functions have an off-by-one that inverts them.&lt;/strong&gt; In a &lt;code&gt;while size &amp;gt; min_size&lt;/code&gt; shrink loop, if &lt;code&gt;min_size&lt;/code&gt; is not strictly below the smallest starting size you call it with, the loop body never executes and your "shrink to fit" silently leaves the text at its original size, overflowing the canvas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the hierarchy at three levels.&lt;/strong&gt; On a 1080x1080 thumbnail, a fourth text level is decoration, not information.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this is worth caring about
&lt;/h2&gt;

&lt;p&gt;AI image generation still mangles CJK text, so any design with a lot of it — posters, data cards, marketing images, before/after comparisons — has to be drawn in code. That makes these three traps load-bearing: they are on the critical path of an entire category of work, and all three are silent.&lt;/p&gt;

&lt;p&gt;The library is a single file with no dependencies beyond Pillow. Take it, or take the three checks and port them into whatever renderer you already have. Just do not rely on the renderer to tell you it failed. It will not.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/yff124/cjk-text-render" rel="noopener noreferrer"&gt;github.com/yff124/cjk-text-render&lt;/a&gt; (MIT)&lt;/p&gt;

</description>
      <category>python</category>
      <category>fonts</category>
      <category>typography</category>
      <category>pillow</category>
    </item>
  </channel>
</rss>
