<?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: MORINAGA</title>
    <description>The latest articles on DEV Community by MORINAGA (@morinaga).</description>
    <link>https://dev.to/morinaga</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%2F3907455%2F8e6a4a13-bec8-4ec0-bc2d-ec192b7880f8.png</url>
      <title>DEV Community: MORINAGA</title>
      <link>https://dev.to/morinaga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/morinaga"/>
    <language>en</language>
    <item>
      <title>Five changes I made after exhausting GitHub Actions free minutes twice</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:05:05 +0000</pubDate>
      <link>https://dev.to/morinaga/five-changes-i-made-after-exhausting-github-actions-free-minutes-twice-5c88</link>
      <guid>https://dev.to/morinaga/five-changes-i-made-after-exhausting-github-actions-free-minutes-twice-5c88</guid>
      <description>&lt;p&gt;The billing block hit in May. I cleared it, changed nothing structural, and it hit again in late June. The same five workflows exhausting the same 2,000-minute free tier, but faster as I'd added more daily jobs.&lt;/p&gt;

&lt;p&gt;I could have upgraded to a paid plan. Instead I spent one session auditing where the minutes were actually going, and found the answer wasn't "the pipeline does too much" — it was "the pipeline reinstalls dependencies from scratch on every run, and fires on commits it doesn't need to care about." That's fixable without touching output frequency.&lt;/p&gt;

&lt;p&gt;This post is the diff: what I changed across five workflow files, which changes should move the needle most, and one concurrency decision that looked simple but had a hidden trap. The savings figures are estimates from observed job durations and run frequency, not a controlled before-and-after billing measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: where the minutes were actually going
&lt;/h2&gt;

&lt;p&gt;Before optimizing, I needed to understand the composition. The pipeline runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;yt-publish&lt;/code&gt;: daily Short, ~6-8 minutes (ffmpeg + edge-tts + Playwright for OG)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yt-publish-longform&lt;/code&gt;: weekly, ~15-20 minutes (same stack plus mermaid-cli for diagram slides)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;publish-articles&lt;/code&gt;: daily article drain, ~5-7 minutes (Playwright for OG images)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;yt-analytics&lt;/code&gt;: daily, ~3-4 minutes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bluesky-queue&lt;/code&gt;: daily, ~2-3 minutes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ci&lt;/code&gt;: 4-parallel build on every push, ~8-10 minutes per job&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CI job was the quiet killer. Every content commit — articles, yt-queue updates, Bluesky queue refills, trends snapshots — was triggering a full 4-parallel build. Content commits happen 3-5 times per day. At 40 minutes per fire, that's 120-200 minutes daily from CI alone, on commits that touched no code.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/single-ci-pipeline-two-youtube-channels-three-seo-sites"&gt;single CI pipeline design&lt;/a&gt; made sense for a project this size; the trigger scope did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pip requirements files — the largest estimated saving
&lt;/h2&gt;

&lt;p&gt;Every Python workflow was installing dependencies inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install --quiet edge-tts Pillow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No lockfile. No explicit pip download cache. On every run, pip may resolve and download packages again. &lt;code&gt;setup-python&lt;/code&gt; caches pip's download cache, not an installed virtual environment, so installation still runs; the expected saving is reduced network and resolution work rather than a zero-cost restore.&lt;/p&gt;

&lt;p&gt;The fix is two parts. First, extract each workflow's dependencies into a requirements file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .github/requirements-yt-publish.txt
edge-tts
Pillow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .github/requirements-publish-articles.txt
playwright==1.50.0
pyyaml==6.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, tell &lt;code&gt;setup-python&lt;/code&gt; to cache based on that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.12"&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pip"&lt;/span&gt;
    &lt;span class="na"&gt;cache-dependency-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/requirements-yt-publish.txt"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Python deps&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install --quiet -r .github/requirements-yt-publish.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cache-dependency-path&lt;/code&gt; ties cache invalidation to the dependency file. With pinned requirements as key input, a dependency change produces a new cache key. It still does not guarantee a hit or restore installed packages.&lt;/p&gt;

&lt;p&gt;Playwright's chromium browser is a special case — &lt;code&gt;pip cache&lt;/code&gt; doesn't cover the browser binary download. I added a separate &lt;code&gt;actions/cache@v4&lt;/code&gt; step with a static key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Playwright chromium&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.cache/ms-playwright&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-chromium-${{ runner.os }}-1.50.0&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Playwright browser (chromium only)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python -m playwright install --with-deps chromium&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The static key means the cache is valid until the Playwright version changes. Chromium downloads ~120MB; caching it saves 3-4 minutes per run on the workflows that use it. The &lt;a href="https://dev.to/articles/playwright-og-images-no-image-api"&gt;OG image generation&lt;/a&gt; and &lt;a href="https://dev.to/articles/content-quality-gate-lint-audit-articles"&gt;article publishing pipeline&lt;/a&gt; both use Playwright — those two workflows' Playwright installs were adding up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The npm global cache for mermaid-cli
&lt;/h2&gt;

&lt;p&gt;The longform video pipeline uses mermaid-cli to render &lt;code&gt;.mmd&lt;/code&gt; diagram files into PNG slides, as I described in the &lt;a href="https://dev.to/articles/mermaid-matplotlib-ci-youtube-slides"&gt;mermaid and matplotlib slide pipeline&lt;/a&gt;. Installing it is slow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @mermaid-js/mermaid-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@mermaid-js/mermaid-cli&lt;/code&gt; pulls puppeteer which downloads a bundled Chromium. On a cold runner: 8-12 minutes. Running once per week, that's 35-50 minutes monthly from one install command.&lt;/p&gt;

&lt;p&gt;The fix needs two pieces. First, a &lt;code&gt;package.json&lt;/code&gt; for the cache key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.github/mermaid-cli-package.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@mermaid-js/mermaid-cli"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"latest"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the cache step and guarded install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;
    &lt;span class="na"&gt;cache-dependency-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/mermaid-cli-package.json"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache global mermaid-cli&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.npm-global&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mermaid-cli-${{ runner.os }}-${{ hashFiles('.github/mermaid-cli-package.json') }}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install mermaid-cli (diagram slides)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;npm config set prefix ~/.npm-global&lt;/span&gt;
    &lt;span class="s"&gt;export PATH="$HOME/.npm-global/bin:$PATH"&lt;/span&gt;
    &lt;span class="s"&gt;echo "$HOME/.npm-global/bin" &amp;gt;&amp;gt; "$GITHUB_PATH"&lt;/span&gt;
    &lt;span class="s"&gt;if ! command -v mmdc &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then&lt;/span&gt;
      &lt;span class="s"&gt;npm install -g @mermaid-js/mermaid-cli&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;~/.npm-global&lt;/code&gt; path isn't in &lt;code&gt;actions/cache&lt;/code&gt;'s default coverage — you have to specify it explicitly. The &lt;code&gt;if ! command -v mmdc&lt;/code&gt; guard means a cache hit skips the install entirely. Cold: 10+ minutes. Warm: ~30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger pruning — the conceptual change
&lt;/h2&gt;

&lt;p&gt;The CI workflow had a push trigger with no path restrictions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every commit triggered a 4-parallel build. That includes every content-generation bot commit. Adding &lt;code&gt;paths-ignore&lt;/code&gt; was one line of YAML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs/**"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Content and docs changes don't affect build behavior. The &lt;a href="https://dev.to/articles/four-github-actions-etl-patterns-monorepo-scheduling"&gt;four GitHub Actions patterns&lt;/a&gt; I've built all avoid mixing content commits with code CI for exactly this reason — they have different failure modes and different stakes.&lt;/p&gt;

&lt;p&gt;The second trigger pruning was on &lt;code&gt;publish-articles&lt;/code&gt;. That workflow had both a push trigger (fires when a new &lt;code&gt;.md&lt;/code&gt; file is committed) and a daily cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content/articles/**/*.md"&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cron already handles the cadence correctly. It's idempotent — articles already published to Dev.to or Hashnode are skipped on re-runs. The push trigger was adding ~17 redundant runs per week (roughly matching the article generation cadence), each installing Playwright and pnpm from scratch. I dropped the push trigger and kept only the cron.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/github-actions-cron-scheduling-patterns-monorepo"&gt;cron scheduling patterns&lt;/a&gt; post covers the case for cron-as-cadence-engine in more depth. The short version: push triggers are for reacting to code changes; content pipelines shouldn't depend on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency discipline — and the Bluesky exception
&lt;/h2&gt;

&lt;p&gt;For three regenerable-data workflows — &lt;code&gt;yt-analytics&lt;/code&gt;, &lt;code&gt;refresh-content&lt;/code&gt;, and &lt;code&gt;trends-fetch&lt;/code&gt; — I flipped &lt;code&gt;cancel-in-progress&lt;/code&gt; from &lt;code&gt;false&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yt-analytics&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These jobs fetch data and write it to the repo. If a new run starts while the old one is still running, the old run's output will be overwritten anyway. Canceling it early is just faster.&lt;/p&gt;

&lt;p&gt;The Bluesky queue workflow stayed at &lt;code&gt;cancel-in-progress: false&lt;/code&gt;. This is the trap I mentioned earlier.&lt;/p&gt;

&lt;p&gt;In June I hit a &lt;a href="https://dev.to/articles/bluesky-jsonl-queue-daily-posts-no-scheduler"&gt;duplicate-post incident&lt;/a&gt; caused by a different cancellation pathway: a queue runner posted a Bluesky entry but was interrupted before committing the queue update that marks it as sent. The next run re-read the same entry as unposted and posted it again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cancel-in-progress: true&lt;/code&gt; creates that exact race structurally. Post happens → cancel fires before queue-file commit → next run reposts. For jobs where the side effect is idempotent or reversible, cancel-in-progress is safe. For Bluesky posts, the side effect is a public post that can't be unsent by the pipeline. The 2-3 minutes of overlap cost is less than the cost of a duplicate post.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/four-github-actions-cron-timing-bugs-daily-pipelines"&gt;cron timing bugs post&lt;/a&gt; has a section on exactly this pattern — treating cancel-in-progress as a safe default rather than thinking about what happens if a cancel fires at each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked, what didn't, what I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Worked well&lt;/strong&gt;: The pip requirements files and the mermaid-cli cache together. These are pure wins with no tradeoffs — same output, less time, no new failure modes. The paths-ignore on CI was similarly clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worked with caveats&lt;/strong&gt;: The static Playwright cache key. It works until Playwright releases a version bump. When it does, the cached chromium won't match. I need to manually update the key in the YAML — which I will forget at some point. A version-aware key would be safer: &lt;code&gt;playwright-chromium-${{ runner.os }}-${{ hashFiles('.github/requirements-publish-articles.txt') }}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Didn't do well&lt;/strong&gt;: I didn't instrument before I cut. I estimated minute consumption from first principles rather than pulling GitHub's usage reports. The estimates were directionally correct but I don't know how accurate. GitHub has per-workflow usage reporting in the billing settings — I should check it monthly, not wait until the block hits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would do differently from day one&lt;/strong&gt;: Write requirements files at project start. Writing &lt;code&gt;pip install edge-tts Pillow&lt;/code&gt; inline in YAML is fast at the time and creates months of cache miss debt. &lt;code&gt;requirements.txt&lt;/code&gt; is three lines of work that pays back immediately.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;How many free minutes does GitHub Actions give?&lt;/strong&gt;&lt;br&gt;
2,000 Linux runner-minutes per month, per account (both free-plan personal accounts and free-plan organizations). Windows is 1x but counts double; macOS counts 10x. Linux is almost always the right default for CI. See the &lt;a href="https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions" rel="noopener noreferrer"&gt;GitHub Actions billing docs&lt;/a&gt; for current limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does pip caching actually restore correctly?&lt;/strong&gt;&lt;br&gt;
Yes, if you use &lt;code&gt;cache-dependency-path&lt;/code&gt; pointing to a pinned requirements file. The cache key hashes the file; a requirements change busts the cache and re-installs. Without the path hint, the cache can match incorrectly across different dependency sets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which workflows should keep &lt;code&gt;cancel-in-progress: false&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Any workflow that writes non-idempotent side effects: posting to external APIs, sending notifications, updating queue state that drives future runs. Map the side effects first. If any step writes something that can't be safely re-done or reverted, keep cancel-in-progress off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I avoid &lt;code&gt;actions/cache&lt;/code&gt; for Playwright?&lt;/strong&gt;&lt;br&gt;
You can use &lt;code&gt;setup-python cache: pip&lt;/code&gt; alone, but pip doesn't cache the browser binaries that &lt;code&gt;playwright install&lt;/code&gt; downloads. You need the separate &lt;code&gt;actions/cache@v4&lt;/code&gt; step pointed at &lt;code&gt;~/.cache/ms-playwright&lt;/code&gt; to capture the chromium download.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related&lt;/strong&gt;: &lt;a href="https://dev.to/articles/four-github-actions-cron-timing-bugs-daily-pipelines"&gt;Four GitHub Actions cron timing bugs that silently broke my daily pipelines&lt;/a&gt; · &lt;a href="https://dev.to/articles/bluesky-jsonl-queue-daily-posts-no-scheduler"&gt;How I schedule three daily Bluesky posts from a JSONL queue&lt;/a&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>webdev</category>
      <category>programming</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Notable this week: Inkling open weights, GitHub Models sunset, Supabase Multigres</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:04:59 +0000</pubDate>
      <link>https://dev.to/morinaga/notable-this-week-inkling-open-weights-github-models-sunset-supabase-multigres-15p</link>
      <guid>https://dev.to/morinaga/notable-this-week-inkling-open-weights-github-models-sunset-supabase-multigres-15p</guid>
      <description>&lt;p&gt;Sunday curated reading. I maintain three AI-curated directory sites and track open-weight model releases and tooling changes for practical reasons: new models land in my Top AI Tools ETL pipeline, and anything touching the GitHub or Supabase ecosystem affects my OSS alternatives directory directly. Five things from this week worth annotating.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Inkling — first open-weight model from Thinking Machines Lab
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://thinkingmachines.ai/news/introducing-inkling/" rel="noopener noreferrer"&gt;Announced July 15&lt;/a&gt; under Apache 2.0. Thinking Machines Lab is Mira Murati's company, founded more than a year after she left OpenAI — this is their first public model. Inkling is a 975B-parameter MoE where roughly 41B parameters are active per forward pass. It was trained on 45 trillion tokens across text, image, audio, and video, and reasons natively across all four modalities. Weights are on HuggingFace with a 1M context window; the Tinker API drops that to 256K but provides hosted inference.&lt;/p&gt;

&lt;p&gt;The Apache 2.0 license is clean — no "non-commercial only" carve-outs, no "you can't compete with us" provisions I can find. What I don't know yet is whether native audio input actually changes anything useful for content generation workflows like mine, or whether "trained on audio" means something different from "usable for audio-adjacent tasks." I'll add Inkling to my AI tools directory listing queue this week and benchmark it against the ETL prompts I currently run on Haiku before drawing any conclusions about model swap feasibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. GitHub Models retires July 30 — ten days left
&lt;/h2&gt;

&lt;p&gt;GitHub &lt;a href="https://github.blog/changelog/2026-06-16-github-models-is-no-longer-available-to-new-customers/" rel="noopener noreferrer"&gt;announced in June&lt;/a&gt; it was closing the free model playground, with the hard shutdown confirmed for July 30. That covers the playground, model catalog, inference API, and bring-your-own-key (BYOK). Brownouts ran July 16; next one is July 23.&lt;/p&gt;

&lt;p&gt;I stopped using GitHub Models months ago when I standardized on Claude Haiku via the Anthropic API. But a lot of solo developers I've seen in HN comments relied on it for low-volume prototyping before committing to a billing relationship with any specific provider. The official migration path is Azure AI Foundry. Because GitHub Models spoke the OpenAI-compatible format, the mechanical part of migration is usually just a base URL and key swap — the heavier cost is that Azure's pricing model doesn't have a free tier in the same sense. The product always had a designed off-ramp built in: experiment here, graduate to Azure when you're ready to scale. That arc completed on schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Supabase Multigres is now open source
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://supabase.com/changelog/47796-developer-update-july-2026" rel="noopener noreferrer"&gt;Supabase July 2026 developer update&lt;/a&gt;: the Multigres Kubernetes operator is now fully open source. It covers direct pod management, zero-downtime rolling upgrades, pgBackRest PITR backups, and OpenTelemetry tracing. The same update includes a TanStack DB alpha integration that syncs collections with Supabase tables over PostgREST and Realtime, and Wrappers v0.6.2 adding MongoDB join support from Postgres.&lt;/p&gt;

&lt;p&gt;I already had a Supabase yt-longform video spec queued up in my pipeline before this roundup — the git commit is timestamped before I drafted this — so that connection isn't retrofitted. The Multigres open-source release matters to the OSS alternatives directory because Supabase is listed as an alternative to several managed-Postgres products, and a production-grade Kubernetes operator being freely available changes the self-hosting calculus. I'll watch whether actual self-hosted Supabase deployments increase in the GitHub ETL signal over the next 30 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. CodeQL now detects system prompt injection in JavaScript and TypeScript
&lt;/h2&gt;

&lt;p&gt;From the GitHub Changelog this week: CodeQL added a JS/TS query targeting untrusted values flowing into AI model system prompts without sanitization. The rule catches the pattern where user-controlled or externally-sourced data reaches a &lt;code&gt;system:&lt;/code&gt; parameter in LLM API calls directly.&lt;/p&gt;

&lt;p&gt;This is the class of vulnerability I've been informally aware of and formally sloppy about. My ETL passes game descriptions, model metadata, and README snippets from Steam, GitHub, and HuggingFace into Claude Haiku prompts. None of that goes through a sanitization pass — my threat model is that the external APIs don't supply adversarial content, which is true until it isn't. The CodeQL query gives me a concrete way to surface which call sites are the highest-risk. I'll run it on the packages that do prompt construction and treat the output as a prioritized list, not necessarily a mandate to fix everything it flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. GitHub Copilot agentic browser tools are generally available
&lt;/h2&gt;

&lt;p&gt;Now &lt;a href="https://github.blog/changelog/" rel="noopener noreferrer"&gt;GA by default in VS Code&lt;/a&gt;, no flag needed. Copilot agents can navigate web pages, inspect DOM content, capture screenshots, and validate web app behavior from within the editor. Parallel agent sessions and visible per-chat cost are also included.&lt;/p&gt;

&lt;p&gt;I don't use Copilot — my agentic workflow runs on Claude Code — so this doesn't change my daily setup. What I'm watching is whether browser-navigating agents in VS Code become standard CI tooling or stay in the "advanced feature" tier. For my kind of deployment (Cloudflare Pages, Vercel, static sites with OG images and JSON-LD I want to verify post-deploy), the ability to check a live page from the same session that deployed it is genuinely useful. If Claude Code ships comparable browser tooling, I'd use it. If Copilot's GA adoption makes this the assumed baseline, that changes what readers expect from articles about verification pipelines.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>One env flag that strips affiliate CTAs for AdSense review — without touching code</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 20 Jul 2026 08:34:57 +0000</pubDate>
      <link>https://dev.to/morinaga/one-env-flag-that-strips-affiliate-ctas-for-adsense-review-without-touching-code-2136</link>
      <guid>https://dev.to/morinaga/one-env-flag-that-strips-affiliate-ctas-for-adsense-review-without-touching-code-2136</guid>
      <description>&lt;p&gt;After &lt;a href="https://dev.to/articles/accidental-low-value-signals-adsense-four-rejections"&gt;four AdSense rejections&lt;/a&gt;, I did a more careful read of what the ossfind.com pages looked like to a human reviewer. The verdict: affiliate CTAs, an Amazon product widget, and "sister site" cross-links to aiappdex and findindiegame — together, they made the site read as a revenue-motivated content network rather than a standalone editorial resource. That's a rejection signal I had underestimated.&lt;/p&gt;

&lt;p&gt;I decided to try for approval on ossfind specifically. But stripping those revenue channels to pass review and then re-adding them after would normally mean multiple code deploys. I didn't want that risk — one botched re-activation and the affiliate links silently don't appear.&lt;/p&gt;

&lt;p&gt;The solution was a single environment variable: &lt;code&gt;PUBLIC_REVIEW_MODE=1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it hides
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;getMonetization()&lt;/code&gt; function in &lt;code&gt;packages/shared/src/monetization/index.ts&lt;/code&gt; returns an &lt;code&gt;enabled&lt;/code&gt; object that each Astro component checks before rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reviewMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_REVIEW_MODE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adsense&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;adsenseClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amazon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;amazonTag&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reviewMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reviewMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;newsletter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;newsletterAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ga4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;ga4Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;PUBLIC_REVIEW_MODE=1&lt;/code&gt; is set in Cloudflare Pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enabled.amazon&lt;/code&gt; → false&lt;/strong&gt;: &lt;code&gt;AmazonRecommend.astro&lt;/code&gt; renders nothing. No product widget, no affiliate tag in any URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enabled.affiliate&lt;/code&gt; → false&lt;/strong&gt;: All hosting referral CTAs (DigitalOcean, Hetzner, Vultr, RunPod, Vast.ai) render nothing. The GPU affiliate links I wrote about &lt;a href="https://dev.to/articles/three-gpu-affiliate-programs-ai-tool-directory"&gt;wiring into the AI tools directory&lt;/a&gt; are hidden.&lt;/li&gt;
&lt;li&gt;Sister-site cross-links: these are controlled separately in templates by checking &lt;code&gt;!reviewMode&lt;/code&gt; directly. In review mode the nav and footer drop the "Also see: aiappdex.com / findindiegame.com" links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Crucially, &lt;code&gt;enabled.ads&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; gated by &lt;code&gt;reviewMode&lt;/code&gt;. The AdSense script tag needs to be present for account domain verification and for the review itself. Hiding it would defeat the purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it at the environment level, not as a code branch
&lt;/h2&gt;

&lt;p&gt;I could have added a &lt;code&gt;REVIEW_MODE&lt;/code&gt; boolean to the TypeScript config object and deployed. But the key property I wanted was &lt;em&gt;zero code change&lt;/em&gt; on both entry and exit.&lt;/p&gt;

&lt;p&gt;Setting a Cloudflare Pages environment variable triggers an automatic redeploy. Removing it triggers another. No PR, no review, no merge. If I later decide mid-review that I want to re-enable Amazon links to test something, I can do it without touching source. The audit trail lives in Cloudflare's env change log, not in git commits.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;PUBLIC_&lt;/code&gt; prefix matters for Astro: environment variables without it are server-only. &lt;code&gt;PUBLIC_REVIEW_MODE&lt;/code&gt; is read at build time by &lt;code&gt;getMonetization()&lt;/code&gt; inside &lt;code&gt;packages/shared&lt;/code&gt;, which runs during Astro's SSG build. The value bakes into the static HTML — there's no runtime re-evaluation. That's correct behaviour for a static site: the entire site builds clean with all affiliate links removed, and any cached CDN responses reflect that clean state.&lt;/p&gt;

&lt;p&gt;Cloudflare Pages &lt;a href="https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables" rel="noopener noreferrer"&gt;environment variable updates&lt;/a&gt; trigger an automatic redeploy of the project, which is the mechanism that makes this pattern work without manual CI intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring after approval
&lt;/h2&gt;

&lt;p&gt;One Cloudflare Pages action: remove &lt;code&gt;PUBLIC_REVIEW_MODE&lt;/code&gt; from the project's environment variables. On the next redeploy (automatic, triggered by the env change), all affiliate and Amazon paths re-enable wherever the existing code checks &lt;code&gt;enabled.affiliate&lt;/code&gt; and &lt;code&gt;enabled.amazon&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing in the TypeScript needs to change. The pattern is closer to a feature flag than a code branch — which is why I used the env layer instead of &lt;code&gt;git checkout -b review-clean&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I don't know yet how long AdSense review takes, or whether they re-crawl after an initial pass. &lt;a href="https://dev.to/articles/why-im-abandoning-adsense-two-sites-betting-affiliate-monetization"&gt;Affiliate monetization is the primary strategy now&lt;/a&gt; for two of the three sites regardless, so this review mode only applies to ossfind. I'll publish numbers when there's something real to report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still haven't figured out
&lt;/h2&gt;

&lt;p&gt;Whether AdSense reviews manually or with a bot. The rejection emails have all been form letters with category codes, not specific page URLs. So I don't know whether a reviewer is clicking through a live site or scraping a cached snapshot. If it's the latter, the redeploy timing matters in a way I can't control.&lt;/p&gt;

&lt;p&gt;I also don't know if the &lt;code&gt;newsletter&lt;/code&gt; field should be gated by &lt;code&gt;reviewMode&lt;/code&gt;. Newsletter forms are probably neutral to AdSense, but I'm not certain. Right now they stay visible during review — if there's another rejection I'll check that as well.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>vercel</category>
    </item>
    <item>
      <title>How I built Bluesky summary cards in Python — YAML frontmatter to 1080 1350 PNG</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 20 Jul 2026 08:34:52 +0000</pubDate>
      <link>https://dev.to/morinaga/how-i-built-bluesky-summary-cards-in-python-yaml-frontmatter-to-1080x1350-png-1p7d</link>
      <guid>https://dev.to/morinaga/how-i-built-bluesky-summary-cards-in-python-yaml-frontmatter-to-1080x1350-png-1p7d</guid>
      <description>&lt;p&gt;The standard article OG image I generate for &lt;a href="https://aiappdex.com" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt; is 1200×630 — the landscape aspect ratio that works well on Twitter, Discord, and most link-preview boxes. That system &lt;a href="https://dev.to/articles/playwright-og-images-no-image-api"&gt;uses Playwright and zero image API calls&lt;/a&gt;, which I described earlier.&lt;/p&gt;

&lt;p&gt;Bluesky is different. When a post links to an article that has a &lt;code&gt;summary_image&lt;/code&gt; URL in its &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tags, Bluesky renders that image in portrait format — and the native crop target is 1080×1350, which is the ratio of a phone screen. A landscape image rendered at that scale ends up letterboxed and small. I wanted something that looked intentional at 1080×1350, so I built a second image pipeline.&lt;/p&gt;

&lt;p&gt;The result: &lt;code&gt;generate-summary.py&lt;/code&gt;, a Python script that reads a &lt;code&gt;summary_data&lt;/code&gt; block from each article's frontmatter, renders one of three visual layouts via an inline HTML template, and screenshots it with Playwright Chromium at the exact pixel dimensions. No external API, no third-party image service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a separate YAML schema instead of using the title alone
&lt;/h2&gt;

&lt;p&gt;My first instinct was to generate the summary image from the article title: large text, dark background, brand mark, done. I already do something like that for cover images, so the code exists.&lt;/p&gt;

&lt;p&gt;The problem is that article titles are often too long for a 1080×1350 canvas at a readable font size. "How I built a shared Claude Haiku client with system-prompt caching for batch ETL" is 84 characters. At 76px bold, that overflows. And even when it fits, a wall of title text doesn't communicate structure — it's just a slightly bigger version of the link card's plain text.&lt;/p&gt;

&lt;p&gt;So I designed a &lt;code&gt;summary_data&lt;/code&gt; YAML block that each article can opt into. The schema has five keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;summary_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;title_html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YAML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;→&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;accent&amp;gt;Bluesky&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;card&amp;lt;/accent&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;1080×1350&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PNG,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;zero&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cost"&lt;/span&gt;
  &lt;span class="na"&gt;cards&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🤖"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aiappdex.com"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;directory"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIVE"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🎮"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;findindiegame.com"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Indie&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;game&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIVE"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔓"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ossfind.com"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OSS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;alternatives"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIVE"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;pipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ETL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fetch"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Haiku&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gen"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Turso"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IndexNow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;
  &lt;span class="na"&gt;stats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;num&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$25"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cost"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;num&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sites"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;num&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;880"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pages&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;daily"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;num&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6mo"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;horizon"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;tagline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;honest&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6-month&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;indie&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;experiment"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;title_html&lt;/code&gt; is the headline. It supports &lt;code&gt;\n&lt;/code&gt; for line breaks and &lt;code&gt;&amp;lt;accent&amp;gt;...&amp;lt;/accent&amp;gt;&lt;/code&gt; tags to highlight specific words in a contrasting colour. All other keys are for the visual zone below the title: &lt;code&gt;cards&lt;/code&gt; for a three-column comparison grid, &lt;code&gt;pipeline&lt;/code&gt; for a sequential step flow, and &lt;code&gt;stats&lt;/code&gt; for a four-number data row. Only one of those three should appear in any given article — the renderer handles whichever key is present and renders nothing for the others.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tagline&lt;/code&gt; is the footer line, always shown if present. It falls back to "An honest indie experiment" if absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accent tag and why XSS-paranoia matters even in local tools
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;title_html&lt;/code&gt; field is rendered server-side into an HTML string, which then runs in a headless browser. In theory, this is a local tool with no user input, so SQL injection isn't the threat model. But I've built enough internal tools that "local only" stops being true quickly — and the real reason to do this right is code that future-me can audit easily.&lt;/p&gt;

&lt;p&gt;The approach: everything in &lt;code&gt;title_html&lt;/code&gt; gets &lt;code&gt;html.escape()&lt;/code&gt; by default. The only exception is &lt;code&gt;&amp;lt;accent&amp;gt;...&amp;lt;/accent&amp;gt;&lt;/code&gt; tags, which the renderer finds with a narrow regex, escapes their inner text separately, and re-injects as &lt;code&gt;&amp;lt;span class="accent"&amp;gt;&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;render_title_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;accent&amp;gt;(.+?)&amp;lt;/accent&amp;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;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()]).&lt;/span&gt;&lt;span class="nf"&gt;replace&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;br/&amp;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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;span class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;br/&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/span&amp;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;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:]).&lt;/span&gt;&lt;span class="nf"&gt;replace&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;br/&amp;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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;\n&lt;/code&gt;-to-&lt;code&gt;&amp;lt;br/&amp;gt;&lt;/code&gt; replacement happens after escaping, not before, so there's no way to inject markup through newlines. The outer text never runs through the browser as raw HTML. If someone puts &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in their &lt;code&gt;title_html&lt;/code&gt;, it renders as literal &lt;code&gt;&amp;amp;lt;script&amp;amp;gt;&lt;/code&gt; on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three visual modes
&lt;/h2&gt;

&lt;p&gt;The visual zone below the title picks its layout from whichever key appears in &lt;code&gt;summary_data&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cards&lt;/strong&gt; — a three-column grid for "three things compared" framing. Each card has an emoji icon, a short domain or label, a 50-80 character description, and an optional UPPERCASE stat tag. I use this for articles that describe all three directory sites, or for comparison articles (tool A vs B vs C).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline&lt;/strong&gt; — a five-step horizontal flow with numbered circles and arrows between them. Good for articles that describe sequential processes. The step labels support &lt;code&gt;\n&lt;/code&gt; for two-line labels when the text is long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stats&lt;/strong&gt; — a four-number row for articles with memorable metrics. The number gets rendered large in &lt;code&gt;#FCD34D&lt;/code&gt; (amber) and the label in smaller uppercase grey. I use this for articles with cost, duration, or count data that's worth highlighting.&lt;/p&gt;

&lt;p&gt;If none of these three keys appear, the script generates nothing for that article and the article's Bluesky post falls back to the standard landscape &lt;code&gt;cover_image&lt;/code&gt;. The &lt;a href="https://dev.to/articles/bluesky-pre-post-qc-gate-four-gates"&gt;pre-post QC gate&lt;/a&gt; doesn't care which image field is used — it only cares that &lt;em&gt;some&lt;/em&gt; image is present before the post goes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playwright rendering and the frontmatter backfill
&lt;/h2&gt;

&lt;p&gt;The script reuses a single Playwright browser context across all articles in the batch:&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;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&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;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_frontmatter&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;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meta&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;summary_data&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;summary&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;continue&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&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;out_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;clip&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;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&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;&lt;code&gt;wait_until="networkidle"&lt;/code&gt; matters here. The HTML template loads Inter from Google Fonts. If the screenshot fires before the CSS is applied, the fallback system font renders instead and the result looks wrong. &lt;code&gt;networkidle&lt;/code&gt; blocks until the font CDN request completes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;clip&lt;/code&gt; parameter is important too. The &lt;code&gt;context&lt;/code&gt; viewport is set to 1080×1350, but &lt;code&gt;page.screenshot()&lt;/code&gt; without a clip argument would capture whatever the page actually renders to — which can be shorter if the content doesn't fill the full height. The explicit clip forces exactly the declared dimensions regardless of content height.&lt;/p&gt;

&lt;p&gt;After generating the PNG, the script calls &lt;code&gt;update_summary_image_field()&lt;/code&gt;, which re-reads the article file and adds a &lt;code&gt;summary_image:&lt;/code&gt; line to its frontmatter if it isn't already there:&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;summary_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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/og/summary/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug_base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;new_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(---\n)([\s\S]*?)(\n---\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;lambda&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;summary_image: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;count&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means I never have to manually add the &lt;code&gt;summary_image:&lt;/code&gt; URL to an article I wrote. I add &lt;code&gt;summary_data&lt;/code&gt;, run the script, and the field appears. The downstream &lt;a href="https://dev.to/articles/bluesky-jsonl-queue-daily-posts-no-scheduler"&gt;Bluesky queue refill&lt;/a&gt; reads that field when it constructs a post for the article.&lt;/p&gt;

&lt;p&gt;The output path is &lt;code&gt;apps/ai-tools/public/og/summary/&amp;lt;slug&amp;gt;.png&lt;/code&gt;, which Astro copies to &lt;code&gt;/og/summary/&amp;lt;slug&amp;gt;.png&lt;/code&gt; during the static build. The same PNG is then served from &lt;code&gt;aiappdex.com&lt;/code&gt; — the same domain that hosts all three sites' OG images, rather than spreading them across three different domains.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/auto-generating-youtube-thumbnails-ffmpeg-ci-pipeline"&gt;CI pipeline that runs generate-og.py&lt;/a&gt; runs generate-summary.py in the same step, immediately after. Both scripts reuse the same Playwright installation, so there's no double browser-download cost in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the visual mode per article
&lt;/h2&gt;

&lt;p&gt;My decision tree for which &lt;code&gt;summary_data&lt;/code&gt; to write:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline&lt;/strong&gt;: the article describes a sequential process with clear stages. Most technical "how I built X" articles fall here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cards&lt;/strong&gt;: the article compares or describes exactly three things. The three-site overview article, model comparison articles, tool roundup articles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stats&lt;/strong&gt;: the article has four numbers worth remembering. Cost, page count, API call count, duration. If fewer than four numbers, I either pad with something defensible or skip stats in favour of pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Omit&lt;/strong&gt;: the article is a lightweight, recap, or curated list. The frontmatter doesn't have a clean structured story to tell. The Bluesky URL card uses &lt;code&gt;cover_image&lt;/code&gt; instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/content-quality-gate-lint-audit-articles"&gt;content quality gate&lt;/a&gt; doesn't validate &lt;code&gt;summary_data&lt;/code&gt; — that's outside its scope. But the generator script itself validates silently: a malformed YAML block causes &lt;code&gt;meta.get("summary_data")&lt;/code&gt; to return a non-dict, and the article is skipped without error. I've been thinking about adding a dry-run validation step there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Embed Inter as Base64 instead of fetching from Google Fonts.&lt;/strong&gt; The &lt;code&gt;wait_until="networkidle"&lt;/code&gt; approach works, but it depends on outbound internet access from the CI runner. In a cold environment or a GitHub Actions runner with network restrictions, the font fetch can fail silently — Playwright renders with the fallback font but doesn't raise an exception. I should detect this, either by checking that the font loaded successfully or by embedding the subset as Base64 in the HTML. The &lt;a href="https://dev.to/articles/youtube-slide-renderer-pillow-eight-kinds-no-browser"&gt;YouTube slide renderer&lt;/a&gt; uses Pillow with a local &lt;code&gt;.ttf&lt;/code&gt; font for exactly this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dead reference at the top.&lt;/strong&gt; The script defines &lt;code&gt;TEMPLATE_PATH = ROOT / "scripts/summary_template.html"&lt;/code&gt; but never uses it — the actual template is &lt;code&gt;HTML_TEMPLATE&lt;/code&gt;, an inline constant in the same file. I initially intended to read from the file, then inlined it for portability, and forgot to remove the reference. It's harmless but I notice it every time I open the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async Playwright for batch performance.&lt;/strong&gt; The current script uses &lt;code&gt;sync_playwright&lt;/code&gt; and processes articles sequentially. On a full regeneration pass over 70+ articles, that's noticeable — maybe 3-4 minutes in CI. The Playwright Python library has an async version; batching 10 concurrent &lt;code&gt;page.set_content&lt;/code&gt; calls would cut that significantly. I haven't bothered because the script only runs in &lt;a href="https://dev.to/articles/playwright-og-images-no-image-api"&gt;the og-image CI step&lt;/a&gt;, not on the critical path of any content publish.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not just use an image generation API?&lt;/strong&gt;&lt;br&gt;
For 70 articles, an API at even $0.02/image would cost $1.40 per full regeneration pass. In CI that runs daily, that's $42/month. Playwright Chromium is already installed because I use it for &lt;a href="https://dev.to/articles/playwright-og-images-no-image-api"&gt;OG images&lt;/a&gt; and for Bluesky &lt;a href="https://dev.to/articles/bluesky-image-upload-cloudflare-pages-race-fix"&gt;image upload race detection&lt;/a&gt;. Zero marginal cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I want to update the card design across all articles?&lt;/strong&gt;&lt;br&gt;
Change the &lt;code&gt;HTML_TEMPLATE&lt;/code&gt; constant and rerun the script against all articles. The whole batch regenerates in a few minutes. Since the slug-based filenames are stable, the CDN-cached URLs stay the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use more than one visual mode per article?&lt;/strong&gt;&lt;br&gt;
The renderer will display all three that are present in &lt;code&gt;summary_data&lt;/code&gt;. But the canvas is 1350px tall and three modes together push the tagline below the fold. In practice I pick one. The template was designed for exactly that: &lt;code&gt;cards&lt;/code&gt; fills the middle section, &lt;code&gt;pipeline&lt;/code&gt; fills it, &lt;code&gt;stats&lt;/code&gt; fills it. Two modes at once makes both feel cramped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/articles/playwright-og-images-no-image-api"&gt;What I learned generating OG images for articles with Playwright and zero API cost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/articles/youtube-slide-renderer-pillow-eight-kinds-no-browser"&gt;How I built a YouTube slide renderer in Python — eight kinds, no browser&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>indiehackers</category>
      <category>programming</category>
    </item>
    <item>
      <title>What I'm watching this week: GPT-5.6 Sol, Steam Machine, Krea 2, and two more</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:56:08 +0000</pubDate>
      <link>https://dev.to/morinaga/what-im-watching-this-week-gpt-56-sol-steam-machine-krea-2-and-two-more-4jm3</link>
      <guid>https://dev.to/morinaga/what-im-watching-this-week-gpt-56-sol-steam-machine-krea-2-and-two-more-4jm3</guid>
      <description>&lt;p&gt;Five things from this week's HN and release feeds that I actually read and thought about. I run three AI-curated directories — Top AI Tools, Find Games Like, Open Alternative To — so "worth watching" means something affected my sourcing, ETL planning, or how I'd categorize things.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPT-5.6 Sol — government vetting before access
&lt;/h2&gt;

&lt;p&gt;OpenAI announced GPT-5.6 Sol on June 26 (HN: 713 points). The follow-up story got more attention in the comments: the US government will vet users before granting access to Sol, making it the first major frontier model gated by something other than a credit card and API key.&lt;/p&gt;

&lt;p&gt;I'm not building with GPT-5.6 Sol. My pipeline runs Claude Haiku 4.5 for ETL because latency and cost matter more than ceiling performance at the volume I operate. But the vetting arrangement creates a categorization problem for &lt;a href="https://aiappdex.com" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt;: a model that exists but isn't freely accessible doesn't fit cleanly into the same listing format as one you can call tomorrow with an API key. I'm going to add a "restricted access" flag to the directory schema and see how it affects search behavior. The gap between "exists" and "available" is going to widen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steam Machine relaunches
&lt;/h2&gt;

&lt;p&gt;Valve's new Steam Machine hardware line was the highest-scored HN story this week at 1,025 points on June 22. The first run (2015–2018) ended quietly; the new version arrives after the Steam Deck proved Linux-native gaming hardware has real demand. These are SteamOS-native PCs targeting the living room.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://findgameslike.com" rel="noopener noreferrer"&gt;findgameslike.com&lt;/a&gt;, Steam-based game discovery is 80% of what I surface. If Steam Machine gains meaningful market share, the couch-gaming segment grows and my existing Steam-first recommendations stay well-aligned. I'm not changing anything yet — a launch announcement isn't a sales number — but it's going in my "things to revisit in Q3" list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Krea 2 — 12B open-weights image generation
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://krea.ai" rel="noopener noreferrer"&gt;Krea&lt;/a&gt; released Krea 2, a 12-billion-parameter image generation model under open weights, distributed via HuggingFace. The benchmark numbers put it near the top of the open-weights image gen category right now, ahead of several larger models.&lt;/p&gt;

&lt;p&gt;I don't use ML inference for image generation today. My &lt;a href="https://dev.to/articles/youtube-slide-renderer-pillow-eight-kinds-no-browser"&gt;YouTube slide renderer&lt;/a&gt; assembles frames with Pillow and pre-rendered templates, which is faster and cheaper for my CI budget. But I track image gen models because the architectural pattern keeps compressing — 12B this year, probably 4B-distilled next year, probably usable in constrained CI the year after. Krea 2 is the kind of release I add to the AI tools directory and watch for community adoption velocity.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenAI's first custom chip, designed with Broadcom
&lt;/h2&gt;

&lt;p&gt;OpenAI announced its first proprietary inference chip on June 24 (HN: 417 points), built with Broadcom on TSMC 3nm. The announcement was thin on specs — inference-optimized, no published performance numbers, no timeline for external access.&lt;/p&gt;

&lt;p&gt;The question for builders isn't whether we'll run this chip — we won't. It's what custom silicon means for pricing strategy. Labs that control inference costs can discount selectively: lower prices on models they want to commoditize, keep proprietary tiers expensive. I wrote about &lt;a href="https://dev.to/articles/why-im-betting-cross-channel-distribution-months-1-6"&gt;why I'm betting on cross-channel distribution over months 1–6&lt;/a&gt;; one of the background assumptions in that bet is that frontier API prices stay roughly flat. Custom silicon moves that variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenKnowledge — open-source alternative to Obsidian and Notion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/inkeep/open-knowledge" rel="noopener noreferrer"&gt;inkeep/open-knowledge&lt;/a&gt; launched as a Show HN on June 25 and reached 151 points. It's a local-first, AI-first personal knowledge base that targets both Obsidian and Notion as alternatives. The differentiator is LLM-assisted retrieval integrated from day one, rather than bolted onto an existing feature set.&lt;/p&gt;

&lt;p&gt;This goes directly into &lt;a href="https://ossfind.com" rel="noopener noreferrer"&gt;ossfind.com&lt;/a&gt;. The interesting decision is how to categorize it: as a Notion alternative (collaboration/docs), an Obsidian alternative (personal knowledge), or a new category (AI-native knowledge). I'm currently using a &lt;a href="https://dev.to/articles/four-signals-oss-decision-score-no-fabricated-reviews"&gt;four-signal scoring system for OSS directory decisions&lt;/a&gt;; OpenKnowledge clears three of the four — GitHub activity, license clarity, and differentiated framing. The fourth signal (adoption trajectory) I'll check again in 30 days.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>machinelearning</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Notable this week: Open Source AI state report, Comic Chat, LM Studio Bionic, SQLite</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:56:04 +0000</pubDate>
      <link>https://dev.to/morinaga/notable-this-week-open-source-ai-state-report-comic-chat-lm-studio-bionic-sqlite-30gn</link>
      <guid>https://dev.to/morinaga/notable-this-week-open-source-ai-state-report-comic-chat-lm-studio-bionic-sqlite-30gn</guid>
      <description>&lt;p&gt;Sunday reading round-up. I run three AI-curated directory sites and check HN a few times a week for things that either affect my stack directly or give me something honest to write about on the content side. This week had five things worth annotating.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. stateofopensource.ai — a reference document I'll keep returning to
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://stateofopensource.ai/" rel="noopener noreferrer"&gt;stateofopensource.ai&lt;/a&gt; hit 340 HN points July 17. I clicked through expecting marketing copy and got something closer to an actual dataset: license comparisons across the major open-weight releases, training compute estimates, and capability benchmarks organized by release date.&lt;/p&gt;

&lt;p&gt;What's useful for me: I'm constantly adding new models to my AI tools directory, and I need a consistent framework for what "open" actually means in each case. This report is specific. It separates "open weights" (download the checkpoint), "open training data" (reproducible pretraining), and "open source" (full stack, permissive license) as distinct categories, and classifies models accordingly. "Open weights" and "open source AI" get conflated constantly — in HN discussions, in model announcements, in my own past writing. Having a reference that draws the line cleanly is something I'll cite when writing model descriptions and when I'm deciding whether a release qualifies for the OSS alternatives directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Microsoft Comic Chat goes open source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://opensource.microsoft.com/blog/2026/07/16/microsoft-comic-chat-is-now-open-source/" rel="noopener noreferrer"&gt;Announced July 16&lt;/a&gt; with 442 HN points. Comic Chat was a 1996 Microsoft IRC client that rendered your chat conversation as a comic strip, with customizable cartoon avatars speaking in panels. The full source is now on GitHub.&lt;/p&gt;

&lt;p&gt;The software itself isn't useful in 2026. What I'm watching is the pattern: Microsoft has now open-sourced several legacy internal products in quick succession. Each release follows the same arc — GitHub trending for 48 hours, nostalgia discussion, occasional "what if someone built X on top of this" thread. The comic-strip chat metaphor has genuine modern appeal for specific communities: accessible communication tools, visual-first interfaces for non-English speakers, creative chat formats for games. Whether anyone picks up the rendering code and builds something new is the question I'll watch over the next few months. I won't list Comic Chat itself in the OSS alternatives directory (there's no modern equivalent context to surface it in), but the release announcement is worth adding to the content queue for historical context posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. LM Studio Bionic brings agent mode to local models
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://lmstudio.ai/blog/introducing-lm-studio-bionic" rel="noopener noreferrer"&gt;lmstudio.ai/blog/introducing-lm-studio-bionic&lt;/a&gt;, 79 HN points July 16. LM Studio is the desktop app for running local LLMs with a GUI. Bionic adds an agent layer on top — tool use, multi-step task execution, the same primitives that make Claude Code and similar tools useful.&lt;/p&gt;

&lt;p&gt;My ETL pipelines currently run on Claude Haiku 4.5 via API because local models haven't cleared the latency bar for anything I run in CI. But there's a category of tasks — draft-and-review loops, non-time-critical enrichment jobs, one-off data transformations where a slow answer is fine — where "free and local" could beat "$0.0002 per call." If LM Studio Bionic makes it easy to point agent workflows at Qwen or Mistral running locally, that's the first realistic path I've seen toward migrating some workloads off the API. The 79 points tells me the dev community is intrigued but not yet convinced. I'm in the same position: watching, not moving anything yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Julia Evans on SQLite — and Lobste.rs switching to it in production
&lt;/h2&gt;

&lt;p&gt;Two separate SQLite pieces landed on HN within 24 hours of each other.&lt;/p&gt;

&lt;p&gt;Julia Evans' &lt;a href="https://jvns.ca/blog/2026/07/17/learning-about-running-sqlite/" rel="noopener noreferrer"&gt;Learning a few things about running SQLite&lt;/a&gt; (110 points) covers journal modes, WAL behavior under concurrent writes, and practical limits she hit in real usage — the kind of post I read twice because it maps directly to tradeoffs I've made without fully understanding. Lobste.rs &lt;a href="https://lobste.rs/s/ko1ji1" rel="noopener noreferrer"&gt;announced they moved to SQLite from Postgres&lt;/a&gt; (87 points), a production social site with real write concurrency.&lt;/p&gt;

&lt;p&gt;I use Turso (libSQL, a SQLite fork with replication) for all three of my directories. My ETL jobs run concurrent upserts on the games and model tables, which is exactly the workload Julia's post covers. I've been on WAL mode since launch and haven't measured whether it's the correct choice or just the default I haven't questioned. The Lobste.rs migration is the more surprising signal — a site with sustained write load choosing SQLite over Postgres in 2026, not as a cost-cutting move but as an engineering preference. That's worth understanding in detail. I'll write a proper note on this after I pull the actual upsert timings from my own ETL jobs rather than guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. MoonBASIC — a modern BASIC for 2D and 3D games
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CharmingBlaze/moonbasic" rel="noopener noreferrer"&gt;github.com/CharmingBlaze/moonbasic&lt;/a&gt;, 28 HN points July 17. Not a big launch. 28 points is "interesting to a niche audience," which is exactly the kind of release I track for the indie game discovery site.&lt;/p&gt;

&lt;p&gt;BASIC-inspired game development languages have a persistent niche: Pico-8, TIC-80, and GBStudio all prove there's a community for constrained creative tools with learnable syntax. MoonBASIC is targeting both 2D and 3D with what looks like clean syntax inspired by the classic BASIC era. The repo is very early stage — I won't add it to the directory yet since there are no games to catalog. But if it develops a community, my GitHub ETL will pick it up via star growth. The signal worth noting is that this niche keeps attracting new entrants; the demand for "programming language you can learn in a weekend to make a game" hasn't peaked.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Five things I noticed this week: Chinese AI surge, GitHub Agentic Workflows, Copilot CLI</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:25:16 +0000</pubDate>
      <link>https://dev.to/morinaga/five-things-i-noticed-this-week-chinese-ai-surge-github-agentic-workflows-copilot-cli-1moe</link>
      <guid>https://dev.to/morinaga/five-things-i-noticed-this-week-chinese-ai-surge-github-agentic-workflows-copilot-cli-1moe</guid>
      <description>&lt;p&gt;Another week of more happening than I can reasonably track. I run three AI-curated directory sites — Top AI Tools, Find Games Like, and Open Alternative To — and I keep a loose eye on the surrounding ecosystem because what ships this week tends to land in my ETL pipelines next month. Here are five things that caught my attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. DeepSeek V4.1 Flash hit top trending on HuggingFace within a week of release
&lt;/h2&gt;

&lt;p&gt;DeepSeek V4.1 Flash climbed to the top trending slot on HuggingFace faster than anything I've seen this quarter. The number that sticks with me: Chinese open-weight models now hold five of the top ten slots — the highest concentration on record according to the HuggingFace trending data I've been watching. I don't have a clean read yet on whether V4.1 Flash is meaningfully better than V3.2 for my specific use case (structured JSON generation from unclean source data), but the download velocity alone is a signal worth noting.&lt;/p&gt;

&lt;p&gt;For my AI tools directory, this means another batch of model cards I need to ingest. I'm not adding models on hype alone — my ETL has a signal-gate that requires GitHub stars + HuggingFace likes above a threshold before a model gets a detail page. V4.1 Flash cleared it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Six competitive Chinese frontier models shipped within two weeks
&lt;/h2&gt;

&lt;p&gt;Qwen 3.7, DeepSeek V4.1, Hunyuan Large 3, ERNIE 5.1, Doubao Pro, and GLM-6 all arrived inside roughly a two-week window. I started calling this the "Chinese frontier convergence" this week because the cadence stopped looking like independent releases and started looking like a coordinated response to Claude Fable 5 and whatever OpenAI shipped at Build.&lt;/p&gt;

&lt;p&gt;The practical effect on my OSS alternatives directory is that the "best open-weight alternatives to [closed model]" comparison pages are going stale faster than my weekly ETL refresh can keep up. I'm going to need to tighten the refresh cadence on those specific pages or accept that they'll be wrong for a few days each cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Transformers 5.12.0 added MiniMax-M3-VL and Parakeet-RNNT
&lt;/h2&gt;

&lt;p&gt;Hugging Face shipped &lt;a href="https://github.com/huggingface/transformers/releases" rel="noopener noreferrer"&gt;Transformers v5.12.0&lt;/a&gt; on June 12 with first-class support for MiniMax-M3-VL, PP-OCRv6, and Parakeet-RNNT. The one I'm watching most is Parakeet-RNNT — a streaming ASR model from NVIDIA. I've been looking for a free, local-runnable alternative to Whisper for my video pipeline, and RNNT architecture is meaningfully lower latency for short-form audio.&lt;/p&gt;

&lt;p&gt;I haven't benchmarked it yet. My current Whisper setup runs fine, and I'm not going to swap a working component for an untested one on a hunch. But I've got a test branch open and I'll run a side-by-side on my standard 90-second video script sample next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. GitHub Agentic Workflows entered public preview
&lt;/h2&gt;

&lt;p&gt;GitHub's Agentic Workflows feature entered public preview this week. The pitch is reasoning-based automation inside GitHub Actions — issue triage, documentation updates, and similar tasks that previously required a human to interpret context before acting. I watched the announcement and immediately thought about my content QC pipeline.&lt;/p&gt;

&lt;p&gt;Right now I run a Claude Haiku step in CI that flags potential frontmatter issues and broken internal links before an article gets committed. That's not "agentic" in the GitHub sense — it's a dumb script that calls an API. Agentic Workflows would let me set up something that reads the issue, reads related open PRs, and decides whether a flagged article is actually broken or just pattern-matched incorrectly. I'm interested but not in a hurry. Public preview means the API surface will change.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. GitHub Copilot CLI redesign went GA, and Claude Fable 5 is inside it
&lt;/h2&gt;

&lt;p&gt;GitHub's redesigned Copilot CLI terminal interface — previewed at Microsoft Build 2026 — went generally available this week. The tabbed UX for issues, pull requests, and gists looks useful, though I'm skeptical I'll use it heavily given how much of my workflow is already automated.&lt;/p&gt;

&lt;p&gt;The more interesting detail: Claude Fable 5 from Anthropic is now one of the available models in GitHub Copilot for Pro+ and above. That puts Fable 5 inside VS Code, JetBrains, and Xcode without requiring a separate Anthropic API key. I'm still on Claude Sonnet 4.6 for most pipeline tasks — it's cheaper for high-volume structured generation — but having Fable 5 accessible in the editor changes the calculus for the reasoning-heavy planning steps I currently do manually.&lt;/p&gt;




&lt;p&gt;That's five observations. Three of them are going to affect my ETL pipelines before the end of the month. The GitHub Agentic Workflows one I'm treating as interesting-to-watch rather than immediately actionable. I'll come back to it when the API stabilizes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Five things I noticed this week: Kimi K3, Bonsai 27B on-device, and the Gemini rebrand</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:25:12 +0000</pubDate>
      <link>https://dev.to/morinaga/five-things-i-noticed-this-week-kimi-k3-bonsai-27b-on-device-and-the-gemini-rebrand-2gm7</link>
      <guid>https://dev.to/morinaga/five-things-i-noticed-this-week-kimi-k3-bonsai-27b-on-device-and-the-gemini-rebrand-2gm7</guid>
      <description>&lt;p&gt;Another week where more shipped than I could realistically process. I run three AI-curated directory sites — Top AI Tools, Find Games Like, and Open Alternative To — so I keep a loose eye on open-weight releases and tooling shifts because what appears on HN this week tends to land in my ETL pipelines next month. Here are five things that caught my attention between July 14 and 17.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Kimi K3 hit 945 HN points as another open frontier model
&lt;/h2&gt;

&lt;p&gt;Moonshot AI posted &lt;a href="https://www.kimi.com/blog/kimi-k3" rel="noopener noreferrer"&gt;Kimi K3&lt;/a&gt; on July 16 under the headline "Open Frontier Intelligence," and the HN thread immediately climbed past 900 points with 571 comments. The framing is deliberately aggressive — they're not positioning it as a cheap alternative but as a direct peer to frontier closed models.&lt;/p&gt;

&lt;p&gt;What I noticed operationally: my HuggingFace ETL sorts by total likes and filters by pipeline tag, but there's no freshness weight. A model sitting at 800 likes from three weeks ago will outscore something that dropped this morning. Kimi K3 is the fourth model in three months where I noticed this stale-ranking problem. I need to add a recency decay factor — probably a half-life of around 14 days applied to the raw like count before sorting. I'll build that into the next ETL update; right now my AI tools directory could be showing K2.7 when K3 is the current version.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bonsai 27B claims phone-level inference
&lt;/h2&gt;

&lt;p&gt;The July 14 HN thread on Bonsai 27B — a 27-billion-parameter model designed to run on consumer hardware including phones — got 315 points with skeptical but engaged comments. The claims involve aggressive quantization targeting Apple Silicon and Android Snapdragon chips.&lt;/p&gt;

&lt;p&gt;The longer-term implication I keep thinking about: if 27B-class models actually achieve useful inference speeds on-device, the category "cloud API vs self-hosted" starts to fracture into three tiers: cloud, server-hosted, and on-device. My directory currently only captures the first two. I'm not adding an on-device filter yet — I don't have user queries confirming people search for it — but I noted the gap. I'll see whether the Bonsai 27B download numbers on HuggingFace actually back up the phone-inference claims in the next few weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. NotebookLM is now Gemini Notebook
&lt;/h2&gt;

&lt;p&gt;Google rebranded NotebookLM to Gemini Notebook on July 16. The HN thread (195 points, 109 comments) was split between people asking whether anything functional changed and people lamenting the loss of a distinctive name under another Gemini umbrella.&lt;/p&gt;

&lt;p&gt;For my AI tools directory this is a concrete data problem. I have "NotebookLM" as a canonical tool entry. The product's name is now different, the landing URL structure changed, and if I don't reconcile that I have a stale entry with a broken or redirected URL. I checked and I hit this exact issue with two other tools this week that quietly rebranded. I'm going to add an &lt;code&gt;alias_names&lt;/code&gt; field to my tool schema so I can track these transitions without creating duplicate entries. Right now I'd just update the name manually, but I've done that three times this month.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. LM Studio added an agent layer called Bionic
&lt;/h2&gt;

&lt;p&gt;LM Studio shipped &lt;a href="https://lmstudio.ai/blog/introducing-lm-studio-bionic" rel="noopener noreferrer"&gt;"Bionic"&lt;/a&gt; on July 16 — their framing is "the AI agent for open models." The HN post got 79 points, mostly discussion comparing it to Jan and Ollama for running agentic workflows locally.&lt;/p&gt;

&lt;p&gt;What caught my attention: six months ago, LM Studio was a model manager. It's now positioning as an agent platform. The tool category changed even though the name didn't. My OSS alternatives directory has LM Studio categorized under "model management," and that category label is now wrong. I've been hitting this drift problem more frequently — tools that start in one category and migrate. I don't have a good automated signal for category drift yet; I catch it by manually reading changelogs, which doesn't scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. My auto-tuner caught something I need to manually verify
&lt;/h2&gt;

&lt;p&gt;From my own pipeline this week: the YouTube analytics auto-tuner flagged that "product framing" videos are outperforming "build-in-public" videos at roughly 2:1 in first-15-second retention. I adjusted this week's scripting directive. Then I went back and looked at what the classifier put in each bucket.&lt;/p&gt;

&lt;p&gt;Three of the "product framing" videos were miscategorized when I initially seeded the training set. So I don't actually know whether the retention signal is real or whether I accidentally built a classifier that's good at predicting its own mislabeled inputs. I've paused the directive update and scheduled a manual audit of the classification labels. I wrote more about the auto-tuner setup in &lt;a href="https://dev.to/articles/three-archetype-signals-youtube-auto-tuner-two-weeks"&gt;Three archetype signals the YouTube analytics auto-tuner surfaced after two weeks&lt;/a&gt; — this week's catch is why that article ends with a note about needing human-labeled ground truth before trusting the output.&lt;/p&gt;




&lt;p&gt;Five things, three of which are going to change something concrete in my ETL or directory schema before the end of the month. The auto-tuner one is the most uncertain — I'll have a cleaner picture once I've done the manual label audit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>opensource</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Three archetype signals the YouTube analytics auto-tuner surfaced after two weeks</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 17 Jul 2026 07:50:48 +0000</pubDate>
      <link>https://dev.to/morinaga/three-archetype-signals-the-youtube-analytics-auto-tuner-surfaced-after-two-weeks-2ebk</link>
      <guid>https://dev.to/morinaga/three-archetype-signals-the-youtube-analytics-auto-tuner-surfaced-after-two-weeks-2ebk</guid>
      <description>&lt;p&gt;The auto-tuner runs daily: &lt;code&gt;scripts/yt-analytics/run.py&lt;/code&gt; reads the last 30 uploads from the &lt;a href="https://developers.google.com/youtube/v3/docs/videos/list" rel="noopener noreferrer"&gt;YouTube Data API v3 &lt;code&gt;videos.list&lt;/code&gt; endpoint&lt;/a&gt;, groups them by archetype, computes age-normalized views/day (excluding videos under 24 hours old), and rewrites &lt;code&gt;docs/yt-today-directive.md&lt;/code&gt;. The generation routine must read that file first—the &lt;a href="https://dev.to/articles/how-i-moved-ai-video-archetype-selection-from-prose-to-code-owned-directive"&gt;directive design is covered elsewhere&lt;/a&gt;; this is about what the live data actually said after two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: the gap between archetypes is larger than I expected
&lt;/h2&gt;

&lt;p&gt;Current median views/day by archetype:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;product_findindiegame&lt;/code&gt; (game-vs-game comparisons): &lt;strong&gt;11 views/day&lt;/strong&gt;, n=19&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;product_ossfind&lt;/code&gt; (OSS alternative comparisons): &lt;strong&gt;2 views/day&lt;/strong&gt;, n=1 — too small to trust&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;build_in_public&lt;/code&gt;: &lt;strong&gt;1 views/day&lt;/strong&gt;, n=2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 11 views/day median for &lt;code&gt;product_findindiegame&lt;/code&gt; comes from a specific pattern in the underlying data, not from the archetype label alone. Named-vs-named game comparisons—where both the indie and the AAA are well-known proper nouns—drove individual Shorts to 162-373 views. Stripping the specific names from the same template (keeping the numeric hook, dropping the game names) collapsed a comparable Short to 77 views. A Short with no recognizable game names reached 5 views.&lt;/p&gt;

&lt;p&gt;The original &lt;a href="https://dev.to/articles/yt-analytics-performance-classifier-video-script-bias"&gt;analytics classifier&lt;/a&gt; tracked archetype vs non-archetype performance. What the two-week data added is specificity about &lt;em&gt;what inside the archetype&lt;/em&gt; drives performance. The auto-tuner now enforces a hard gate on the spec: the title must name two real, recognizable proper nouns before the spec passes the YouTube audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: build_in_public didn't just underperform—it regressed
&lt;/h2&gt;

&lt;p&gt;When I started the channel, posting what I shipped each week seemed natural. One early video hit 34 views, which looked like signal at the time.&lt;/p&gt;

&lt;p&gt;The age-normalized median for that archetype is now 1 view/day. The 34-view video was a month-one outlier; the last two &lt;code&gt;build_in_public&lt;/code&gt; Shorts averaged 8 views each with no growth tail in the first two weeks.&lt;/p&gt;

&lt;p&gt;The directive hard-bans it. The specific concern isn't just that it underperforms—it's that when the 3-in-a-row guard fires on the winning archetype (see below), the generation routine needs a fallback. Without an explicit ban, it might fall back to &lt;code&gt;build_in_public&lt;/code&gt; as a low-resistance option. To prevent that, the Python script maintains &lt;code&gt;DEAD_ARCHETYPES = frozenset({"build_in_public", "meta", "curated", "technical"})&lt;/code&gt; and excludes those explicitly from any fallback path in the directive logic. A fallback into a dead archetype would produce a spec that the analytics engine then counts against the winner's share of the queue—distorting next week's view distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: the 3-in-a-row guard matters even for the winning archetype
&lt;/h2&gt;

&lt;p&gt;Today's directive switched the target to &lt;code&gt;product_ossfind&lt;/code&gt; because &lt;code&gt;product_findindiegame&lt;/code&gt; appeared in the last two uploads. The 3-in-a-row guard is a rule I hardcoded when I noticed two things colliding.&lt;/p&gt;

&lt;p&gt;First, the &lt;a href="https://dev.to/articles/jaccard-duplicate-detection-youtube-shorts-spec-audit"&gt;Jaccard duplicate detection&lt;/a&gt; in the spec audit. When the same archetype runs three days in a row, the opening-line similarity score between consecutive specs rises above the 0.82 threshold and the audit starts blocking specs before they reach the TTS step. The guard fires at two-in-a-row specifically to force a rotation before the audit has to catch a near-duplicate. The audit is the last-resort gate; the guard prevents the situation from reaching it.&lt;/p&gt;

&lt;p&gt;Second, the practical consequence of the guard: today's video will likely underperform a &lt;code&gt;product_findindiegame&lt;/code&gt; spec on the same day. I'm choosing one potentially weaker video to avoid two worse outcomes—a near-duplicate upload that erodes the audience's sense of variety, or a pipeline stall from a failed audit.&lt;/p&gt;

&lt;p&gt;The tradeoff is real and I haven't resolved it cleanly. The right answer might be to build a rotation schedule that forces archetype diversity over a 5-day window rather than triggering off consecutive identical archetypes. That would allow &lt;code&gt;product_findindiegame&lt;/code&gt; to appear on Monday and Thursday without triggering the guard, rather than being blocked after any two consecutive days regardless of the gap between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I don't know yet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;product_ossfind&lt;/code&gt; has one video. The 2 views/day figure is not actionable data—it's a prior that could easily flip with the second video. The auto-tuner requires n≥3 before an archetype enters the ranked comparison; currently &lt;code&gt;product_ossfind&lt;/code&gt; is in an "early data" category and only gets the target slot today because the guard pushed off the clear winner.&lt;/p&gt;

&lt;p&gt;The view counts are also raw, not CTR-weighted or subscriber-normalized. A Short that gets 200 views from algorithm distribution with 3% CTR is a different signal than 200 views from a well-trafficked playlist with 0.1% CTR—but I don't have CTR data accessible from the YouTube Data API at the current tier without going through YouTube Studio. Views/day is the proxy, and it's a noisy one for videos under a week old.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>showdev</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>What I learned adding Jaccard duplicate detection to a YouTube Shorts spec audit</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 17 Jul 2026 07:50:43 +0000</pubDate>
      <link>https://dev.to/morinaga/what-i-learned-adding-jaccard-duplicate-detection-to-a-youtube-shorts-spec-audit-58he</link>
      <guid>https://dev.to/morinaga/what-i-learned-adding-jaccard-duplicate-detection-to-a-youtube-shorts-spec-audit-58he</guid>
      <description>&lt;p&gt;Before the spec audit, my CI pipeline could upload a perfectly formatted video—correct word count, all JSON fields present, proper hook variants—that was structurally identical to something uploaded three days ago. After adding Jaccard similarity checks against the last 30 uploaded specs, those near-duplicates fail in under 50ms before any TTS synthesis or ffmpeg step runs. The threshold calibration matters more than the algorithm: 0.76 for titles and 0.82 for opening 28-word windows, derived from manual inspection of uploaded specs rather than theory.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/yt-analytics-performance-classifier-video-script-bias"&gt;CI pipeline that generates YouTube Shorts&lt;/a&gt; ran for several weeks before I noticed a pattern in the upload history: the titles varied, the game matchups differed, but the opening lines were converging. The AI generating scripts had found a template that worked—open with a hard number, frame an underdog—and was reusing the same sentence skeleton almost verbatim. Not the same video, but close enough that a viewer who'd watched the last five would notice.&lt;/p&gt;

&lt;p&gt;Format validation doesn't catch this. You can verify that a spec has a &lt;code&gt;title&lt;/code&gt;, a &lt;code&gt;script&lt;/code&gt; between 55 and 200 words, at least three &lt;code&gt;hook_variants&lt;/code&gt;, and &lt;code&gt;data_panels&lt;/code&gt; with HTTPS source URLs—and still upload something that feels like a rerun. The &lt;a href="https://dev.to/articles/content-quality-gate-lint-audit-articles"&gt;content quality gate I wrote for articles&lt;/a&gt; has the same limitation: it catches broken frontmatter and missing required fields, but it can't tell whether you've written essentially the same piece twice with different nouns.&lt;/p&gt;

&lt;p&gt;Jaccard similarity is the simplest thing that addresses this. It's used in plagiarism detection and recommendation systems; it's three lines of code with no dependencies; and it's directly interpretable when it fires. The &lt;a href="https://en.wikipedia.org/wiki/Jaccard_index" rel="noopener noreferrer"&gt;formal definition&lt;/a&gt; is intersection over union of two sets. Here's how I applied it to video spec validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Jaccard measures, and what it won't catch
&lt;/h2&gt;

&lt;p&gt;Jaccard similarity between two texts: tokens in both / tokens in either. Tokens are lowercased words longer than two characters, punctuation stripped. The algorithm is order-blind—it treats "Risk of Rain 2 has more reviews than Hollow Knight" and "Hollow Knight has fewer reviews than Risk of Rain 2" as identical. That's a weakness if you're trying to catch intentional paraphrase. It's an acceptable tradeoff here because I'm not trying to catch paraphrase. I'm trying to catch the case where the generation routine reused the same sentence structure and vocabulary because a particular template happened to score well in the &lt;a href="https://dev.to/articles/yt-analytics-performance-classifier-video-script-bias"&gt;analytics classifier&lt;/a&gt;.&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;function&lt;/span&gt; &lt;span class="nf"&gt;tokenSet&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&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="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9 &lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&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;jaccard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&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;aa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;aa&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&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;overlap&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;aa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;size&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;I compute this twice per new spec—once on the full title, once on the first 28 words of the script—and compare against every spec in the last 30 uploads. Title check fires at 0.76; opening check fires at 0.82.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why separate thresholds for titles vs openings
&lt;/h2&gt;

&lt;p&gt;The right threshold depends on how much vocabulary overlap is natural given the content. Titles in this pipeline almost always name two specific games or software products. Two titles covering different matchups share almost no vocabulary. A threshold of 0.76 on the title means "three-quarters of the title tokens appear in both texts"—that only happens when two specs are targeting the same product comparison with nearly identical phrasing.&lt;/p&gt;

&lt;p&gt;Opening lines are harder. The pipeline's best-performing hook pattern leads with a hard number in the first three words: "Risk of Rain 2 has 693,000 Steam reviews while Hollow Knight sits at 400,000." A different spec might open with "Balatro has 380,000 Steam reviews against Final Fantasy XVI's 12,000." These share nearly nothing despite following the same template, because the proper nouns dominate the vocabulary.&lt;/p&gt;

&lt;p&gt;The problem arises when the generation routine gets into a rut after several consecutive &lt;code&gt;product_findindiegame&lt;/code&gt; specs. I've seen it output "X has N reviews, making it the clear winner" across three consecutive specs with only the game names swapped. The Jaccard score for that pattern reached 0.84 on the opening window—above the 0.82 threshold. At 0.70, genuine numeric hooks on different matchups would have occasionally collided. At 0.90, rut cases would slip through.&lt;/p&gt;

&lt;p&gt;Choosing a 28-word window for the opening comparison came from looking at where structurally similar openings diverged from each other. At 20 words, the window was too short to catch real convergence; two specs that opened with different games but the same template still scored below 0.70. At 40 words, unrelated specs that both transitioned into a "here's why this matters" framing started scoring too high due to shared transition vocabulary. Twenty-eight words—roughly the first two sentences of a 55-word Short script—was the inflection point for this corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claims provenance map: tying every assertion to a source
&lt;/h2&gt;

&lt;p&gt;Jaccard catches structural similarity. The &lt;code&gt;claims[]&lt;/code&gt; field catches something different: assertions that are factually grounded but not traceable from the spec itself.&lt;/p&gt;

&lt;p&gt;Every item in &lt;code&gt;claims&lt;/code&gt; must link a specific factual assertion to the HTTPS URL where I confirmed it, and that URL must also appear in &lt;code&gt;data_panels&lt;/code&gt;. The cross-reference constraint prevents a pattern I saw in early generated specs: a &lt;code&gt;claims[]&lt;/code&gt; entry citing a Steam page, but &lt;code&gt;data_panels&lt;/code&gt; containing a different URL that was the actual source the generation routine used. If those diverge, either the claim was generated from a source not recorded in the panels, or the panels list sources that don't back the specific claims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"claims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"claim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Risk of Rain 2 has 693,000 Steam reviews as of 2026-07-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://store.steampowered.com/app/632360/Risk_of_Rain_2/"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"data_panels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Steam review counts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://store.steampowered.com/app/632360/Risk_of_Rain_2/"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit enforces this: every &lt;code&gt;claim.source&lt;/code&gt; must appear in the flat list of URLs extracted from &lt;code&gt;data_panels&lt;/code&gt;. A spec that cites source A in claims but lists source B in panels fails the audit regardless of whether either URL is actually correct.&lt;/p&gt;

&lt;p&gt;Combined with &lt;code&gt;verified_at&lt;/code&gt; (a &lt;code&gt;YYYY-MM-DD&lt;/code&gt; date field required on every spec), this creates a paper trail: each claim has a source, each source has a date when I confirmed it, and the audit verifies the linkage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quarantine instead of halting the queue
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/what-i-learned-building-pipeline-health-monitor-github-issues"&gt;pipeline health monitor&lt;/a&gt; watches the queue at the workflow level—it fires if nothing has shipped in 36 hours. But a spec that fails quality validation is a narrower problem: one bad spec shouldn't stall the day's queue.&lt;/p&gt;

&lt;p&gt;The audit runs as the first step in the CI job, before any compute-intensive work. If it fails, the spec moves to &lt;code&gt;content/yt-queue/rejected/&lt;/code&gt; with the error list appended to its filename, the CI job exits non-zero, and the next spec in the queue is unaffected. The quarantine directory also serves as a record: I've retrieved and manually fixed rejected specs twice, and I've occasionally discovered that a threshold miscalibration was producing false positives before any specs were lost.&lt;/p&gt;

&lt;p&gt;This matters more for the &lt;a href="https://dev.to/articles/two-host-ai-dialogue-spec-youtube-longform"&gt;two-host longform pipeline&lt;/a&gt; than for Shorts. A longform spec takes 8-15 minutes to synthesize and render. A bad spec that passes format validation but fails midway—at the thumbnail generation step, for example—wastes the entire CI run. The &lt;a href="https://dev.to/articles/three-tier-thumbnail-fallback-ci-youtube-longform-pipeline"&gt;three-tier thumbnail fallback pipeline&lt;/a&gt; was partly built in response to a spec that failed at thumbnail generation after TTS had already completed; the quarantine model would have caught it before any of that compute ran.&lt;/p&gt;

&lt;p&gt;With the audit as a fast first gate, failure is cheap: exit non-zero in under 100ms, commit the rejected spec with its error list, continue with the next item. The health monitor's 36-hour threshold is large enough to absorb several consecutive audit failures without alerting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the archetype directive and the spec audit interact
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/how-i-moved-ai-video-archetype-selection-from-prose-to-code-owned-directive"&gt;analytics-driven archetype directive&lt;/a&gt; is the upstream control. If the directive correctly identifies which archetype to produce and the generation routine follows it with sufficient variation in game matchups, the Jaccard audit should rarely fire. Different matchups produce different vocabulary; Jaccard scores for unrelated specs typically stay below 0.40.&lt;/p&gt;

&lt;p&gt;The audit becomes necessary when the directive guidance is followed but the generation routine still converges. That can happen when the same archetype runs for several days and the language model finds a phrase pattern that consistently passes the hook quality checks—not because it's copying verbatim, but because it's exploiting the same structural template.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/four-youtube-shorts-thumbnail-rules-19-videos-analytics"&gt;thumbnail rules derived from 19 videos of analytics&lt;/a&gt; have an analogous property: they enforce visual differentiation at the image layer while the spec audit enforces differentiation at the text layer. Neither catches the other's failure mode—a visually distinct thumbnail can accompany a near-duplicate script, and vice versa.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/why-im-betting-quality-gates-protect-my-adsense-approval-better-than-content-volume"&gt;quality gates article&lt;/a&gt; covers why I think fail-closed gates matter at every layer; this post is about one specific gate and how it's calibrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits I haven't solved
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Semantic drift.&lt;/strong&gt; If the generation routine rotates vocabulary deliberately—"critically acclaimed" instead of "award-winning", "dominating" instead of "winning"—Jaccard scores will be low even if the underlying framing is identical. I don't have a good solution here at the scale of 30 videos. TF-IDF cosine similarity would be more sensitive to rare vocabulary, but the corpus is too small for meaningful IDF weights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lookback window ages out.&lt;/strong&gt; If I publish 30 videos on the same archetype category—roughly seven weeks at current cadence—the oldest specs age out of the window, and a rephrase of the earliest ones could theoretically slip through. I'd need either a longer window or a topic-slug–based deduplication layer that persists beyond the rolling 30.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threshold drift.&lt;/strong&gt; Both thresholds are calibrated to the current script style. If I significantly change the hook pattern or switch to a different generation model, the natural base similarity of both titles and openings will shift and the thresholds will need recalibration. I haven't built any automated way to flag threshold drift; it requires noticing the audit's false positive rate climbing.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why Jaccard and not cosine similarity or BLEU?&lt;/strong&gt;&lt;br&gt;
Jaccard is three lines of code with zero dependencies. Cosine similarity over TF-IDF vectors would be more sensitive to vocabulary frequency, but TF-IDF requires a meaningful corpus for IDF weighting—30 specs is too small. BLEU requires per-test reference translations, which don't apply here. For this scale, Jaccard is accurate enough and trivially debuggable: when the audit fires, I can compute the overlap manually in my head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the lookback include longform specs?&lt;/strong&gt;&lt;br&gt;
Yes, but the similarity checks only compare a new spec against previous specs of the same type. A Short spec is checked against the 30 most recent Short specs; a longform is checked against longform. The &lt;code&gt;isLongform&lt;/code&gt; detection is based on whether the spec has a &lt;code&gt;segments&lt;/code&gt; array rather than a single &lt;code&gt;script&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's actually in the lookback directory?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;content/yt-queue/uploaded/&lt;/code&gt; contains one JSON file per uploaded spec, named by upload date. The audit reads all &lt;code&gt;.json&lt;/code&gt; files in that directory, sorts by filename, and takes the last 30. It does not read from the YouTube API—just from the committed spec files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does the audit take?&lt;/strong&gt;&lt;br&gt;
On the GitHub Actions runner, about 40ms for a queue with 19 uploaded specs. The hot path is the nested loop over uploaded specs (O(n) Jaccard computations per new spec), and both n and the token set sizes are small enough that this doesn't register in the workflow's timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to a rejected spec?&lt;/strong&gt;&lt;br&gt;
It moves to &lt;code&gt;content/yt-queue/rejected/&lt;/code&gt; with the full error list written to a companion &lt;code&gt;.txt&lt;/code&gt; file. The rejected directory is committed and pushed. The generation routine doesn't automatically re-queue rejected specs; I handle those manually or let the next daily run produce a fresh spec for the same topic.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Four YouTube Shorts thumbnail rules I hardcoded after 19 videos of analytics</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 16 Jul 2026 07:52:56 +0000</pubDate>
      <link>https://dev.to/morinaga/four-youtube-shorts-thumbnail-rules-i-hardcoded-after-19-videos-of-analytics-345d</link>
      <guid>https://dev.to/morinaga/four-youtube-shorts-thumbnail-rules-i-hardcoded-after-19-videos-of-analytics-345d</guid>
      <description>&lt;p&gt;When I changed my &lt;a href="https://dev.to/articles/three-view-count-data-lessons-youtube-game-comparison-titles"&gt;YouTube Shorts titles to name both games in the comparison&lt;/a&gt;, the view counts moved. Named-vs-named drove 162–373 views per video; unnamed variants died at 5. That data was enough to start making corresponding changes to the thumbnail design — the visual and the title need to work together or neither works well.&lt;/p&gt;

&lt;p&gt;Here are the four rules I've now hardcoded in the thumbnail generation pipeline after running 19 game-comparison Shorts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: Lead with the big number, not the game title
&lt;/h2&gt;

&lt;p&gt;The original thumbnail layout put the game names at the top — icons, titles, branding — and the statistical result at the bottom: "Has 5× more Steam reviews." This mimics review-site hierarchy: identify the subject first, then reveal the verdict.&lt;/p&gt;

&lt;p&gt;For a 3-second impression on a Shorts feed, this ordering is backwards. The viewer doesn't know to care about the game until they see the result. They need a reason to stop scrolling before they'll register who the comparison involves.&lt;/p&gt;

&lt;p&gt;PR #37 reversed the layout: the comparison number or result goes at the top in large type. Game names and icons move to the lower third. The implicit reading order is now "RESULT → explanation" rather than "subject → result."&lt;/p&gt;

&lt;p&gt;I don't have A/B split data on this specific change yet — it shipped this week. But the title-naming data consistently showed specificity is the click trigger. The number is the most specific element on the thumbnail. It should be the first thing the viewer reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: Wire the cover_image explicitly or the render breaks
&lt;/h2&gt;

&lt;p&gt;The thumbnail generator takes a &lt;code&gt;cover_image&lt;/code&gt; field from the YouTube script JSON. When I added the Hades II Short to the queue, I initially omitted the field assuming the generator would fall back to a sensible default.&lt;/p&gt;

&lt;p&gt;It did not. It defaulted to a black frame. The ffmpeg overlay then rendered the text and game icons correctly onto a black background. The thumbnail was technically valid and looked completely wrong — indistinguishable from a placeholder.&lt;/p&gt;

&lt;p&gt;PR #36 added two fixes: an SOP for how the background image is selected and sourced (platform-appropriate art, no UI chrome, no text overlap zones), and explicit wiring of &lt;code&gt;cover_image&lt;/code&gt; in the Hades queue entry. The SOP was drafted in a code-review session and committed directly to the video generation runbook.&lt;/p&gt;

&lt;p&gt;The rule: &lt;code&gt;cover_image&lt;/code&gt; is not optional. If you're generating thumbnails programmatically, put a gate on the field before the thumbnail step runs. A missing image will silently produce a broken output, not an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: Export at 16:9, not 9:16, for the preview card
&lt;/h2&gt;

&lt;p&gt;YouTube Shorts play vertically at 9:16, so the obvious choice is to generate vertical thumbnails. The problem is where discovery actually happens.&lt;/p&gt;

&lt;p&gt;Thumbnail preview cards in YouTube search results, the main feed, and embed contexts display horizontally. A 9:16 thumbnail that YouTube crops to a 16:9 preview card loses the top and bottom — which is exactly where I'd placed the game title and the result number. The center crop shows only the background art.&lt;/p&gt;

&lt;p&gt;My current export is 1280×720 (16:9). YouTube scales it for the Shorts player by adding sidebars. The data in the preview card — where the viewer decides whether to click — is preserved in full.&lt;/p&gt;

&lt;p&gt;This rule depends on where your Shorts discovery is actually happening. If most of your traffic comes from within the Shorts vertical feed (the swipe-up interface), 9:16 makes sense and my rule doesn't apply. At my follower count, feed cards are the dominant discovery surface, so the horizontal export wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: Hardcode the layout constants, don't prompt for them
&lt;/h2&gt;

&lt;p&gt;The pre-PR thumbnail generator had a prompt that described the desired layout: "put the number at the top in large type, game names below." The results were roughly correct but inconsistent across runs — font sizes drifted, number positions shifted, text occasionally clipped by an icon.&lt;/p&gt;

&lt;p&gt;Prompting treats the layout as a creative decision the model makes fresh each time. For automation, that's the wrong model. I want identical layout across every thumbnail in a series, with only the game names and numbers changing.&lt;/p&gt;

&lt;p&gt;After PR #37, the constraints are constants in the Python generation script, not prose in a prompt:&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;NUMBER_FONT_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;
&lt;span class="n"&gt;NUMBER_POSITION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CENTER_X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TOP_MARGIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;GAME_NAME_FONT_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;
&lt;span class="n"&gt;GAME_NAME_POSITION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CENTER_X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LOWER_THIRD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These aren't configurable parameters. They're invariants. The model doesn't pick them. The only inputs the generator accepts are game names, the comparison number, and the &lt;code&gt;cover_image&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;The same logic applied earlier on the &lt;a href="https://dev.to/articles/ai-video-archetype-selection-prose-to-code-owned-directive"&gt;directive side&lt;/a&gt;: once a decision is settled, move it from a prose instruction into code. A prose instruction can be skimmed past or interpreted loosely. A constant cannot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Why I'm betting quality gates protect my AdSense approval better than content volume</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 16 Jul 2026 07:52:51 +0000</pubDate>
      <link>https://dev.to/morinaga/why-im-betting-quality-gates-protect-my-adsense-approval-better-than-content-volume-1plh</link>
      <guid>https://dev.to/morinaga/why-im-betting-quality-gates-protect-my-adsense-approval-better-than-content-volume-1plh</guid>
      <description>&lt;p&gt;Four AdSense rejections across three sites will change your intuition about content strategy.&lt;/p&gt;

&lt;p&gt;The standard advice for programmatic sites is volume: more pages means more potential entry points, more crawl events, faster indexing feedback. I followed that advice. The rejections tracked the page count up. The more content I had, the more there was for a reviewer to find fault with — and they found fault every time.&lt;/p&gt;

&lt;p&gt;This article is my argument for the opposite bet: that &lt;a href="https://dev.to/articles/content-quality-gate-lint-audit-articles"&gt;fail-closed quality gates&lt;/a&gt; protect AdSense approval better than content velocity does. It's falsifiable, it has a deadline, and I'll name the conditions that would change my mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "fail-closed" actually means in this codebase
&lt;/h2&gt;

&lt;p&gt;A quality gate that produces a warning but still publishes is not a quality gate. It's a suggestion I'm free to ignore when I'm running a batch job at 7am.&lt;/p&gt;

&lt;p&gt;Every article in this project goes through &lt;code&gt;scripts/audit-articles.mjs&lt;/code&gt; before it reaches Dev.to, Hashnode, or Bluesky. The script checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Required frontmatter keys (title, description, tags, publish_to, and the quality_contract v2 keys)&lt;/li&gt;
&lt;li&gt;Tags against an approved pool — anything outside it fails; "seo" fails by name&lt;/li&gt;
&lt;li&gt;Cliché phrases (13 patterns: common AI-writing tells and marketing-speak that no editor would let through)&lt;/li&gt;
&lt;li&gt;Body word count minimum&lt;/li&gt;
&lt;li&gt;Fabricated metric patterns (regexes that catch suspiciously round numbers attached to words like "visits" or "users")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any check fails, the script exits 1. The publish step in the workflow runs only if the audit exits 0. The file stays in the repo as a staged change but does not distribute.&lt;/p&gt;

&lt;p&gt;The directory pages have a parallel gate. &lt;a href="https://dev.to/articles/three-tier-content-quality-ladder-programmatic-etl"&gt;Pages that don't pass the three-tier quality ladder&lt;/a&gt; stay &lt;a href="https://dev.to/articles/noindex-gate-programmatic-pages-without-404s"&gt;noindexed rather than removed&lt;/a&gt; — they exist but aren't surfaced to search engines until the content meets the minimum threshold.&lt;/p&gt;

&lt;p&gt;The gate isn't blocking the majority of articles. In 107 published articles so far, every one has cleared. The gate isn't a band-aid for bad drafting — it's insurance against the tail case where an automated routine produces something that violates a rule I've set. One slipped cliché or one accidentally round visit-count won't make it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The volume counterargument, stated honestly
&lt;/h2&gt;

&lt;p&gt;The case for volume-first is real. A site with 1,000 indexed pages has more surface area than a site with 100. Crawl frequency correlates with update frequency. Freshness signals exist.&lt;/p&gt;

&lt;p&gt;There's also a subtler version: &lt;a href="https://dev.to/articles/why-im-betting-sentence-uniqueness-beats-page-count-adsense-programmatic"&gt;sentence uniqueness matters more than page count&lt;/a&gt; for AdSense review, and volume correlates with uniqueness if you're generating varied content. The argument isn't "more pages = approval" — it's "more diverse content = more signal that this isn't a thin-content farm."&lt;/p&gt;

&lt;p&gt;I accepted this counterargument enough to continue generating content. I have 107 articles and three directory sites with multi-thousand-page indexed content. I haven't stopped publishing. The question is what I prioritize at the margin: does the next hour go into another article or into tightening the gate?&lt;/p&gt;

&lt;p&gt;The empirical problem with volume-first is that it didn't work for me. Three separate applications, each submitted when the sites had more content than the previous one. The rejection reasons didn't cite "not enough pages." They cited &lt;a href="https://dev.to/articles/accidental-low-value-signals-adsense-four-rejections"&gt;low-value content signals&lt;/a&gt;: thin individual pages, template-similar entries, missing editorial context.&lt;/p&gt;

&lt;p&gt;That's a quality problem, not a quantity problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rejection history actually told me
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/accidental-low-value-signals-adsense-four-rejections"&gt;four AdSense rejections&lt;/a&gt; clustered around three categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category 1: Template-similar pages at scale.&lt;/strong&gt; When the AI generates a hundred "Game X is similar to Game Y" pages using the same sentence structure, they're technically unique by character count but visually and semantically uniform. AdSense's automated review reads this as scaled content abuse — a bucket &lt;a href="https://support.google.com/adsense/answer/1348737" rel="noopener noreferrer"&gt;Google explicitly penalizes under its site quality guidelines&lt;/a&gt; and that has grown stricter since 2024.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category 2: Missing editorial voice.&lt;/strong&gt; The early directory pages had data (game price, Steam review count, similar-game links) but no reason why someone would prefer one option over another. They were information delivery without judgment. A page that only restates facts from a source doesn't add value in AdSense's model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category 3: No visible author or purpose.&lt;/strong&gt; The &lt;a href="https://dev.to/articles/eeat-transparency-pages-programmatic-directory"&gt;EEAT transparency pages&lt;/a&gt; I eventually built — &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/methodology&lt;/code&gt;, authorship declaration on articles — were absent in the first application cycles. The site looked like a content farm because there was no evidence of a person running it.&lt;/p&gt;

&lt;p&gt;None of these failures would have been fixed by adding more pages. Adding more template-similar pages would have made category 1 worse. The fix for each was to improve the quality of what existed, not add to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current gate coverage and what it doesn't catch
&lt;/h2&gt;

&lt;p&gt;For articles, the audit script enforces six constraint classes. For directory pages, I have &lt;a href="https://dev.to/articles/four-content-qc-scripts-before-directory-pages-go-live"&gt;four separate QC scripts&lt;/a&gt; that run before pages enter the live index. For Bluesky posts, the &lt;a href="https://dev.to/articles/bluesky-pre-post-qc-gate-four-gates"&gt;four-gate QC filter&lt;/a&gt; rejects posts before they queue.&lt;/p&gt;

&lt;p&gt;The pattern that emerged across all three publishing channels is the same: an automated pipeline without a gate will eventually produce something that violates a constraint I care about. A warning that doesn't block is not enforcement.&lt;/p&gt;

&lt;p&gt;Here's the honest comparison between the two strategies at the page level:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Volume-first&lt;/th&gt;
&lt;th&gt;Gates-first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Indexed page count growth&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-page quality floor&lt;/td&gt;
&lt;td&gt;Variable&lt;/td&gt;
&lt;td&gt;Enforced minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdSense reviewer signal&lt;/td&gt;
&lt;td&gt;More entries to find problems in&lt;/td&gt;
&lt;td&gt;Fewer entries, each harder to reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness / crawl signal&lt;/td&gt;
&lt;td&gt;Stronger&lt;/td&gt;
&lt;td&gt;Weaker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operator time cost&lt;/td&gt;
&lt;td&gt;ETL pipeline time&lt;/td&gt;
&lt;td&gt;Gate logic maintenance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdSense application risk&lt;/td&gt;
&lt;td&gt;Higher variance per page&lt;/td&gt;
&lt;td&gt;Lower variance per page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm betting the right column wins at AdSense review time. The counter to this table is that "lower variance" doesn't mean the quality floor is high enough — it means it's consistently mediocre instead of inconsistently bad. This is the failure mode the gate doesn't catch, and it's the strongest version of the argument against this bet.&lt;/p&gt;

&lt;p&gt;The gate tells me nothing is actively bad. It doesn't tell me anything is good. "No prohibited tags and no clichés" is a necessary condition for AdSense approval, not a sufficient one. If the floor is still below the bar, the gate has been clearing content that's uniformly mediocre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeline and what would change my mind
&lt;/h2&gt;

&lt;p&gt;The bet resolves at month 9 of this experiment, which is January 2027. By then, I expect at least one of the three sites to have cleared AdSense review. If none have, one of these conditions is probably responsible:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Condition A: The gate is solving the wrong problem.&lt;/strong&gt; If rejections continue citing category 1 (template-similar pages) even after I've improved the article gate, the issue is at the directory page level, not the article level. The article gate is working; the page gate needs to be stricter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Condition B: Volume is the actual constraint.&lt;/strong&gt; If the rejection cites "insufficient content" for the first time, I've been wrong about what the reviewer is evaluating. I'll need to run the volume experiment I've been avoiding: generate aggressively for 60 days and reapply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Condition C: EEAT is the constraint, not content quality.&lt;/strong&gt; Author trust signals — inbound links, author profiles across the web, editorial recognition — might matter more than content quality beyond a floor. If this is the failure mode, the gate is necessary but not sufficient.&lt;/p&gt;

&lt;p&gt;I'll write a month-9 update when I have a result. I did the same with the &lt;a href="https://dev.to/articles/ai-directories-vs-google-ai-overviews-bet"&gt;AI overviews vs directories bet from May&lt;/a&gt; — that one resolves at the same time horizon.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/articles/why-affiliate-beats-adsense-new-ai-directories"&gt;affiliate revenue path&lt;/a&gt; is already running as a hedge. Affiliate beats AdSense for new directories at low traffic anyway — the gate bet doesn't hinge on AdSense approval for immediate monetization.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do the gates apply to every article, or only new ones?&lt;/strong&gt;&lt;br&gt;
Only new ones. The audit script runs as a pre-publish check in the CI workflow. Existing published articles aren't retroactively re-checked on each run. I ran a bulk audit once to baseline the existing set, but ongoing enforcement is only at the publish step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the false positive rate on the fabricated metric regex?&lt;/strong&gt;&lt;br&gt;
Not zero. The pattern catches any round large number followed by words like "visitors" or "subscribers" — so a sentence citing a verified subscriber count would also trip it. In practice, I avoid citing any specific metric I haven't directly measured, so the gate's strictness is aligned with the content policy rather than creating real friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If all three sites get rejected at month 9, what's the fallback?&lt;/strong&gt;&lt;br&gt;
The current plan is affiliate monetization indefinitely, with a sale at month 12 based on traffic and content asset value rather than AdSense approval. The sale doesn't depend on AdSense being live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you fail the gate and still publish manually?&lt;/strong&gt;&lt;br&gt;
Yes, by running the publish script directly outside CI. The gate is a CI guardrail, not a cryptographic lock. But the automated routine doesn't have a bypass — any failure stops the routine and requires a manual decision. For an automated publishing system, "requires a manual decision" is effectively a block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would prove the bet was right, not just lucky?&lt;/strong&gt;&lt;br&gt;
If the first site to clear AdSense review is the one with the strictest gate (no clichés, no fabricated metrics, no prohibited tags across all articles), rather than the one with the most pages, that's evidence for gates over volume. If the highest-page-count site clears first, I was wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
