<?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>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/felixyang124/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;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>
