<?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>Three ETL failure patterns I now write into the output file, not just the logs</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:05:44 +0000</pubDate>
      <link>https://dev.to/morinaga/three-etl-failure-patterns-i-now-write-into-the-output-file-not-just-the-logs-4in0</link>
      <guid>https://dev.to/morinaga/three-etl-failure-patterns-i-now-write-into-the-output-file-not-just-the-logs-4in0</guid>
      <description>&lt;p&gt;My Reddit scraper returned empty arrays for 71 days. Nothing broke visibly. The market-listening output file kept being written, kept being committed to git, kept looking like a healthy daily snapshot. Nobody caught it until the interpretation layer noticed that Reddit-sourced signals hadn't changed in two months.&lt;/p&gt;

&lt;p&gt;The three patterns I added afterward are small. Combined, they mean that failure mode can't happen silently again — the artifact itself reports what went wrong, not just the runner logs that nobody reads unless something is already on fire. All three are implemented in &lt;a href="https://github.com/mori7ga2222/seo-farm/blob/main/scripts/market-listening/collect.mjs" rel="noopener noreferrer"&gt;scripts/market-listening/collect.mjs&lt;/a&gt;, with the 71-day blind spot documented in the inline comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: sources_ok lives in the artifact, not the logs
&lt;/h2&gt;

&lt;p&gt;The daily output JSON now contains a &lt;code&gt;sources_ok&lt;/code&gt; object with a boolean per source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sources_ok"&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;"youtube"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autocomplete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bluesky"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"errors"&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="err"&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;If any YouTube request fails, &lt;code&gt;sources_ok.youtube&lt;/code&gt; flips to &lt;code&gt;false&lt;/code&gt;. The file that gets committed to git carries its own health signal. That's the core change: the failure state lives in the artifact, not in a log stream.&lt;/p&gt;

&lt;p&gt;Why this matters over logging: the committed artifact shows up in git diffs, gets reviewed in the daily health check, and is read by the interpretation layer before it uses any of the data. Logs from a GitHub Actions cron are reviewed only if someone suspects a problem. The artifact is reviewed on every downstream read.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl"&gt;pipeline health monitor&lt;/a&gt; checks &lt;code&gt;sources_ok&lt;/code&gt; on every run and opens a GitHub issue if any source is &lt;code&gt;false&lt;/code&gt;. The interpretation layer that reads these files is fail-closed on &lt;code&gt;sources_ok&lt;/code&gt;: if &lt;code&gt;sources_ok.youtube === false&lt;/code&gt;, the YouTube signals for that day are ignored rather than mixed into the weekly aggregate. &lt;a href="https://dev.to/morinaga/what-i-learned-separating-daily-collection-from-weekly-interpretation-in-a-cron-pipeline-45hj"&gt;Separating collection from interpretation&lt;/a&gt; makes this guard practical — the interpretation step reads the artifact and checks health before consuming any of the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: errors[] alongside the results
&lt;/h2&gt;

&lt;p&gt;The boolean &lt;code&gt;sources_ok&lt;/code&gt; tells you something failed. The &lt;code&gt;errors&lt;/code&gt; array tells you what and when:&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;recordError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&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;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ERROR [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;entry&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;Every failed fetch calls &lt;code&gt;recordError&lt;/code&gt; with the source name, the error detail (including HTTP status codes), and an ISO timestamp. These accumulate in the &lt;code&gt;errors&lt;/code&gt; array and are written into the same artifact as the successful results.&lt;/p&gt;

&lt;p&gt;The practical difference: if a run produces &lt;code&gt;errors: [{ "source": "bluesky", "detail": "HTTP 403", "at": "2026-08-13T07:..." }]&lt;/code&gt;, I can see the exact failure mode without opening any runner log. After eight days of that, the error messages have been consistent enough that I know exactly what the Bluesky API is rejecting — not just "bluesky failed."&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;at&lt;/code&gt; timestamp matters too. If a script partially completes and then errors, the timestamps in &lt;code&gt;errors[]&lt;/code&gt; tell you at what point in the run things started going wrong, without reconstructing it from log correlation.&lt;/p&gt;

&lt;p&gt;This is different from the approach I described in &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;catching silent failures in a GitHub Actions pipeline&lt;/a&gt;, which focuses on job-level failures in CI. These failures are internal to a single job that mostly succeeds — partial failures within a healthy-looking run, which CI-level checks won't catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: the vacuous-true guard on .every()
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sources_ok&lt;/code&gt; flag for YouTube is computed by checking whether every query produced valid voted results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;QUERIES&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;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;QUERIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;q&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;queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;reps_ok&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_APPEARANCES&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;recordError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtube&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;one or more queries produced no voted results&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;QUERIES.length &amp;gt; 0&lt;/code&gt;, an accidentally-empty &lt;code&gt;QUERIES&lt;/code&gt; array would satisfy &lt;code&gt;.every()&lt;/code&gt; vacuously — &lt;code&gt;[].every(fn)&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; for any &lt;code&gt;fn&lt;/code&gt;. The script would commit an artifact with &lt;code&gt;sources_ok.youtube = true&lt;/code&gt; and zero actual data inside.&lt;/p&gt;

&lt;p&gt;This is more plausible than it sounds. Any config change that blanks the query list — a bad merge, an over-aggressive deduplication, an env substitution that resolves to empty string — would silently produce an artifact that reports itself as healthy. The length guard is three tokens.&lt;/p&gt;

&lt;p&gt;The same pattern applies to any validation using &lt;code&gt;.every()&lt;/code&gt;, &lt;code&gt;.all()&lt;/code&gt;, or equivalent over a collection that can be empty. If an empty input is a failure condition — and it usually is — guard on length first. The validation chain &lt;code&gt;length &amp;gt; 0 &amp;amp;&amp;amp; every(condition)&lt;/code&gt; handles it cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three patterns give you together
&lt;/h2&gt;

&lt;p&gt;A single fetch failure: &lt;code&gt;sources_ok&lt;/code&gt; flips to false. The git diff shows it. The health check catches it. The interpretation layer ignores that source for that day. The error detail explains what happened.&lt;/p&gt;

&lt;p&gt;A config accident that empties the query list: the length guard trips, the source reports false, the artifact contains no data but reports that explicitly rather than vacuously reporting success.&lt;/p&gt;

&lt;p&gt;An extended outage (like the current Bluesky block): eight consecutive artifacts, each with &lt;code&gt;sources_ok.bluesky = false&lt;/code&gt; and matching error entries. The pattern in the committed history is unmistakable and requires no log archaeology.&lt;/p&gt;

&lt;p&gt;The 71-day Reddit blind spot was eventually caught from the outside — someone noticed the data wasn't changing. These patterns are designed to make that kind of failure visible from the inside, in the artifact itself, immediately on the day it happens.&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>programming</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What I learned about repeat-and-vote sampling for non-deterministic search results</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:05:39 +0000</pubDate>
      <link>https://dev.to/morinaga/what-i-learned-about-repeat-and-vote-sampling-for-non-deterministic-search-results-18d7</link>
      <guid>https://dev.to/morinaga/what-i-learned-about-repeat-and-vote-sampling-for-non-deterministic-search-results-18d7</guid>
      <description>&lt;p&gt;Repeating a YouTube search query three times and keeping only results that appear in two or more of those fetches is more reliable than treating any single fetch as ground truth. The Jaccard similarity between two fetches of the same query — within a single minute — sits around 0.43–0.88. Today's run measured 0.447 for the "more steam reviews than" query. That means roughly half the video IDs flipped between fetches. A single fetch isn't a signal; it's a snapshot of one shuffled result.&lt;/p&gt;

&lt;p&gt;I built this pattern into a daily market-signal collector for three directory sites (AI tools, indie games, open-source alternatives). The &lt;a href="https://dev.to/morinaga/what-i-learned-separating-daily-collection-from-weekly-interpretation-in-a-cron-pipeline-45hj"&gt;collection layer&lt;/a&gt; fetches five YouTube queries every morning, and the &lt;a href="https://github.com/mori7ga2222/seo-farm/blob/main/scripts/market-listening/collect.mjs" rel="noopener noreferrer"&gt;full implementation is in scripts/market-listening/collect.mjs&lt;/a&gt;. This article is about how I made that collection reliable despite YouTube search being inherently non-deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a single YouTube search fetch isn't a signal
&lt;/h2&gt;

&lt;p&gt;YouTube's search ranking is legitimately non-deterministic. Freshness boosts, A/B test bucketing, CDN-level caching, and real-time personalization all interact to produce different rankings on the same query across consecutive requests. This isn't an API bug or detection avoidance — it's how the system works.&lt;/p&gt;

&lt;p&gt;I confirmed this by measuring. The script stores the mean pairwise Jaccard across all rep-pairs per query in the output file. Today's run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Mean pairwise Jaccard&lt;/th&gt;
&lt;th&gt;Items kept&lt;/th&gt;
&lt;th&gt;Items dropped as noise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"more steam reviews than"&lt;/td&gt;
&lt;td&gt;0.447&lt;/td&gt;
&lt;td&gt;measured across 3 reps&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The code stores the mean only — not individual pairwise values — so 0.447 is what I can actually cite. At that value, under half the video IDs appear in both halves of any given fetch-pair. Treating one fetch as "the ranking" means you're looking at roughly a coin flip on which videos actually show up. You can't tell signal from noise from a single observation.&lt;/p&gt;

&lt;p&gt;The problem is worse for market-signal use cases, where you're not trying to understand any one video's rank — you're trying to identify which videos appear consistently over time. A video that appears in one fetch but not the next isn't evidence of anything. Consistent appearance across independent fetches is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repeat-and-vote approach
&lt;/h2&gt;

&lt;p&gt;The fix is three fetches per query, with a jitter delay (5–15 seconds, randomized) between each. A video that appears in fewer than two of the three fetches is filtered out and counted in &lt;code&gt;dropped_noise&lt;/code&gt;. Only items that appear in at least two of three fetches are considered signal.&lt;/p&gt;

&lt;p&gt;Today's run dropped 28 items as noise for the first query. Those videos were surfaced by YouTube's shuffler at least once, but didn't clear the consistency threshold. Some of them might be genuinely relevant; most are one-time shuffler artifacts. I'm trading recall for precision: I'd rather miss a few real signals than include noise that drives incorrect market conclusions.&lt;/p&gt;

&lt;p&gt;A few implementation notes that aren't obvious:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;MIN_APPEARANCES=2&lt;/code&gt; threshold is a parameter. At REPS=3, requiring 2/3 appearances means a video must appear consistently across the majority of fetches. Raising it to 3/3 would be stricter — better for high-confidence signals, worse for coverage. I've run with 2/3 since the collector was built and haven't found a reason to change it.&lt;/p&gt;

&lt;p&gt;The jitter delay is calibrated for request politeness, not cache-busting. The &lt;a href="https://dev.to/morinaga/three-sleep-intervals-for-three-apis-steam-250ms-github-100ms-huggingface-none-4ga7"&gt;sleep intervals&lt;/a&gt; I use across different APIs are different for different services — YouTube gets 5–15s between requests. This introduces some time variance between reps, which helps with cache freshness but isn't guaranteed to force a cache miss.&lt;/p&gt;

&lt;p&gt;I don't use the official YouTube Data API for this. The search HTML surface is sufficient for market-listening purposes, and it avoids a key rotation dependency. I prefer &lt;a href="https://dev.to/morinaga/three-public-http-apis-i-read-daily-without-registering-for-a-key-1aid"&gt;polling public endpoints without API key registration&lt;/a&gt; where the data surface is adequate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Median rank instead of position from a single fetch
&lt;/h2&gt;

&lt;p&gt;Once I determine which videos cleared the consistency threshold, I face a second problem: what rank to assign them. A video might rank 2nd in one fetch, 3rd in another, and 15th in a third. None of those individual positions is trustworthy.&lt;/p&gt;

&lt;p&gt;The solution is median rank. For each kept video, I collect its rank from every rep where it appeared, sort those ranks, and take the median:&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="c1"&gt;// voteReps() in scripts/market-listening/collect.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ranks&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;median_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&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;/&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A video ranked 2, 3, 40 across three reps gets median rank 3. The outlier position (40th) doesn't drag it down. A video ranked 1, 2, 1 gets median 1. For a two-element list — a video that only appeared in 2/3 reps — median is the second element: ranks [2, 8] returns 8, since &lt;code&gt;floor(2/2) = 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I use median specifically because search rank distributions have long tails. A video can appear in position 30+ on a bad shuffle while legitimately being a top-10 result. Mean would be dragged by those outlier positions; min would reward lucky placements. Median is stable against both. I've applied &lt;a href="https://dev.to/morinaga/what-i-learned-adding-jaccard-duplicate-detection-to-a-youtube-shorts-spec-audit-58he"&gt;the same outlier-resistance property when using Jaccard for duplicate detection&lt;/a&gt; — the logic generalizes across signal-extraction problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jaccard as a built-in diagnostic
&lt;/h2&gt;

&lt;p&gt;After computing the vote for each query, the script computes the mean Jaccard similarity across all pairs of reps for that query and stores it in the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;reps&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;reps&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="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&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;reps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;reps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&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="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;jaccard_mean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pairs&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;pairs&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="nf"&gt;toFixed&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="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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 every daily file in &lt;code&gt;data/market-listening/&lt;/code&gt; carries a per-query Jaccard mean. It's not just an implementation detail — it's the primary diagnostic for whether the repeat-and-vote pattern is still justified.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;jaccard_mean&lt;/code&gt; climbs toward 1.0 for a query, the shuffler has become deterministic for that query. Either caching locked in, or the query narrowed to so few results there's no variation left. That's the cue to lower REPS (three fetches of the same cached result isn't three independent samples) or rethink the query entirely.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl"&gt;pipeline health monitor&lt;/a&gt; reads these files daily. I could add Jaccard-based alerts — flag if any query exceeds 0.9 (suspiciously deterministic) or drops below 0.25 (unusually chaotic). I haven't added those yet because 0.447 has been stable enough that I don't have a calibrated threshold. This is an honest gap in the current implementation.&lt;/p&gt;

&lt;p&gt;The Jaccard function itself:&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="k"&gt;export&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;A&lt;/span&gt; &lt;span class="o"&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;a&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;B&lt;/span&gt; &lt;span class="o"&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;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;B&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;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="o"&gt;++&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;inter&lt;/span&gt; &lt;span class="o"&gt;/&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;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two empty sets return 1 (vacuously equal). That edge case matters: if two reps both returned empty results, this would report perfect similarity — which is wrong from a health standpoint. I handle this upstream via the &lt;code&gt;sources_ok&lt;/code&gt; logic, which fails a source if any query produced zero accepted results. But the Jaccard function itself doesn't surface that failure; it needs external context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this approach breaks down
&lt;/h2&gt;

&lt;p&gt;The repeat-and-vote pattern assumes independent fetches. Several failure modes break that assumption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN-level caching.&lt;/strong&gt; If all three requests hit the same YouTube edge cache, they receive the same response — effectively one sample, not three. The jitter delay and randomized inter-rep timing help, but can't guarantee cache-misses. Watching &lt;code&gt;jaccard_mean&lt;/code&gt; over time is the only way to catch this from the outside; a sustained climb toward 1.0 across all queries simultaneously is the clearest signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress IP stability.&lt;/strong&gt; GitHub Actions runners can change egress IPs between runs but typically don't change between steps within a single run. If all three reps within a run use the same egress IP, they might receive the same regional ranking. This is less likely than CDN determinism, but it's a real risk for queries with strong geo-sensitivity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal autocorrelation.&lt;/strong&gt; The 5–15s jitter between reps is short enough that trending content could genuinely rank differently between rep 1 and rep 3. For market-signal purposes, where I want stable-over-time signals, this is actually fine — a video that's trending at second-level resolution isn't what I'm tracking. But if you were trying to detect short-lived viral content, the jitter window would need to collapse.&lt;/p&gt;

&lt;p&gt;What I'd do differently now: log per-query Jaccard variance alongside the mean. The mean tells me how similar the fetches were on average, but variance would tell me whether they were clustered (three fetches all near 0.45) or spread out (one at 0.2, one at 0.8). That's diagnostic information I don't currently have. I'd also add &lt;code&gt;reps_attempted&lt;/code&gt; vs. &lt;code&gt;reps_succeeded&lt;/code&gt; at the per-rep level, so I can identify which specific fetch failed without reading runner logs.&lt;/p&gt;

&lt;p&gt;The broader pattern — collect, vote, report the vote metadata — applies to any non-deterministic API. The implementation details change (different HTML parsing, different jitter), but the logic is the same. And having Jaccard built into the output gives you a continuous health signal rather than having to re-instrument later when something starts behaving differently. This is related to what I cover in &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;catching silent ETL failures&lt;/a&gt; and applies equally to &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;survivorship-bias concerns in any analytics pipeline&lt;/a&gt; — the data you collect determines what you can conclude, and knowing its collection conditions is part of using it responsibly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not use the YouTube Data API's search endpoint instead?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;search.list&lt;/code&gt; endpoint has its own caching and returns different results than the search HTML surface in ways that are harder to inspect. More importantly, I prefer polling &lt;a href="https://dev.to/morinaga/three-public-http-apis-i-read-daily-without-registering-for-a-key-1aid"&gt;public endpoints without registering for a key&lt;/a&gt; where sufficient — every API key is a credential to rotate and a quota to manage. For market-listening at this scale, the HTML surface is adequate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if one of the three reps fails to return any results?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A failed rep reduces the pool for voting. If only one rep succeeds, nothing can reach the &lt;code&gt;MIN_APPEARANCES=2&lt;/code&gt; threshold and the query produces zero accepted items. This flips &lt;code&gt;sources_ok.youtube&lt;/code&gt; to false in the output artifact, and the &lt;a href="https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl"&gt;daily health check&lt;/a&gt; surfaces it. It's not silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would REPS=5 produce better results?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Probably yes, at linear cost. Each additional rep adds several minutes to the run (5–15s jitter per request × 5 queries). For a daily market-signal that only needs directional accuracy, REPS=3 filters most single-fetch noise without being prohibitively slow. I'd increase it if I needed sub-day granularity or was tracking a more volatile query set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this approach work for other search APIs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The repeat-and-vote pattern and the Jaccard diagnostic are search-API-agnostic. You'd change the HTML extraction and the appropriate jitter timing, but the voting logic and the sources_ok/Jaccard output structure are reusable. The only input the vote function needs is a list of per-rep result sets where each item has a stable ID and a rank.&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>programming</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What 14 days of YouTube hook A/B parity data revealed—and what it's hiding</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:09:30 +0000</pubDate>
      <link>https://dev.to/morinaga/what-14-days-of-youtube-hook-ab-parity-data-revealed-and-what-its-hiding-4ijm</link>
      <guid>https://dev.to/morinaga/what-14-days-of-youtube-hook-ab-parity-data-revealed-and-what-its-hiding-4ijm</guid>
      <description>&lt;p&gt;I've been running a hook A/B test on my YouTube Shorts for 14 days. The variant arm leads on views-per-day by a meaningful-looking margin. The data is also telling me that the margin is almost entirely explained by one outlier video on each side — not by the hook text.&lt;/p&gt;

&lt;p&gt;Here's what the numbers look like and why I'm not changing anything yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test setup
&lt;/h2&gt;

&lt;p&gt;The YouTube short-generation pipeline assigns hook arm by the publish day's UTC parity. Even UTC day = &lt;strong&gt;CONTROL&lt;/strong&gt;: number-first hook, leading with a concrete data point. Odd UTC day = &lt;strong&gt;VARIANT&lt;/strong&gt;: story-first or underdog-emotion hook, burying the data inside the script rather than leading with it.&lt;/p&gt;

&lt;p&gt;Every video spec now records &lt;code&gt;"hook_arm": "control"&lt;/code&gt; or &lt;code&gt;"hook_arm": "variant"&lt;/code&gt; in its JSON. The analytics history appends this to the per-video JSONL on every daily fetch. The A/B logic is described in &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;the survivorship bias fix write-up&lt;/a&gt; — without full-fleet JSONL, there would be no arm-level data to analyze.&lt;/p&gt;

&lt;p&gt;The 14-day window (2026-07-29 to 2026-08-12) covers 11 videos: 3 in the control arm, 8 in the variant arm. The imbalance is calendar arithmetic — videos only upload when specs are ready, not on a fixed cadence, so the even/odd split doesn't guarantee equal arm sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 14-day numbers
&lt;/h2&gt;

&lt;p&gt;Parity-inferred medians (these are computed from current views-per-day, not age-controlled day-7 data — the JSONL doesn't have enough history for day-7 on all 11 videos yet):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arm&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Videos&lt;/th&gt;
&lt;th&gt;Median vpd&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control (even day)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Brotato/AC Mirage, Undertale/Spider-Man, Project Zomboid&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.3 vpd&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variant (odd day)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Geometry Dash, Binding of Isaac, RimWorld/FF7R, Undertale/Jul29, Don't Starve, Celeste, Valheim, Among Us&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.4 vpd&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two-day threshold for flagging a directional signal — variant leading for 2 consecutive days — was crossed today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confound
&lt;/h2&gt;

&lt;p&gt;The control arm contains one video with 0.7 vpd: Brotato/AC Mirage. This is a "flop anchor" pair. AC Mirage had &lt;a href="https://dev.to/morinaga/three-archetype-signals-the-youtube-analytics-auto-tuner-surfaced-after-two-weeks-2ebk"&gt;poor anchor quality relative to the live-table rankings&lt;/a&gt; at the time of publication, and the video's performance reflects that.&lt;/p&gt;

&lt;p&gt;The variant arm contains Geometry Dash (18.7 vpd, age 3 days). This video uses a current live-table anchor with EA FC 26 — a different quality tier from anything in the control arm. Geometry Dash's high early vpd is consistent with a strong anchor pairing, not with the hook text being particularly effective.&lt;/p&gt;

&lt;p&gt;When I exclude one outlier from each arm — Geometry Dash from variant, Brotato from control — the medians converge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Variant (n=7, ex-Geometry Dash): &lt;strong&gt;6.0 vpd&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Control (n=2, ex-Brotato): &lt;strong&gt;5.7 vpd&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 0.3 vpd gap on n=2 vs n=7 is not interpretable. The original 2.1 vpd gap was driven by which anchor each arm happened to get, not by the hook text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the outliers ended up in these arms
&lt;/h2&gt;

&lt;p&gt;The arm assignment is mechanical: even UTC publish day = control, odd = variant. Geometry Dash was published on an odd day because the generation routine fired on that day. Brotato was published on an even day for the same reason. Neither assignment was intentional from a design standpoint — the calendar parity doesn't track anchor quality.&lt;/p&gt;

&lt;p&gt;This is the core design problem: the arm assignment correlates with publish date, and anchor quality partially correlates with publish date (because the live-table ranking changes over time, and the best anchors get used when specs happen to be ready). Getting a clean measurement requires either randomizing anchor quality across arms or stratifying — making sure each arm has a comparable distribution of anchor tiers.&lt;/p&gt;

&lt;p&gt;Currently the live-table anchor gate &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;ensures minimum quality (R6b compliance)&lt;/a&gt; but doesn't control for distribution within arms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm waiting for before drawing a conclusion
&lt;/h2&gt;

&lt;p&gt;Three conditions need to be met before the hook text comparison is valid:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tagged data instead of inferred data.&lt;/strong&gt; The &lt;code&gt;hook_arm&lt;/code&gt; field was added partway through the 14-day window. Several of the videos in the parity table are "parity-inferred" — I know their publish day and computed their arm from that, but they don't have &lt;code&gt;hook_arm&lt;/code&gt; in their spec JSON. Inferred arms are correct, but they make the history brittle to any change in how the pipeline runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. At least 5 tagged observations per arm.&lt;/strong&gt; The current control arm has n=3, with one outlier consuming most of the signal. At n=5 (minimum), excluding one outlier still leaves 4 data points — enough to compute a median that isn't dominated by the sample distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Anchor parity between arms.&lt;/strong&gt; Before publishing the next batch of videos under the hold, I'm tracking which anchor tier lands in which arm. If the next three control-arm videos all get weak anchors and the next three variant-arm videos get strong anchors again, the arm comparison stays confounded regardless of sample size.&lt;/p&gt;

&lt;p&gt;The anchor parity check is the one I can't automate easily with the current pipeline. It requires comparing the live-table ranking of the anchor at the time of publish across the two arms — which means storing the table state at generation time, not just the anchor name. That's a data collection change, not a filtering change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 2-day threshold is actually telling me
&lt;/h2&gt;

&lt;p&gt;The "variant leads for 2 consecutive days" signal is real and worth noting. It means there's a genuine directional trend in the current data, not one-day noise. But the threshold was designed to trigger review, not to trigger a directive change. The next step is the anchor-parity audit, not switching all future videos to the variant hook.&lt;/p&gt;

&lt;p&gt;If after 5 tagged observations per arm with comparable anchors the variant still leads, that's a different situation. At that point the hook text itself is the more plausible explanation for the gap.&lt;/p&gt;

&lt;p&gt;Until then: record &lt;code&gt;hook_arm&lt;/code&gt; in every new spec JSON, and wait.&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>programming</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What I learned separating daily collection from weekly interpretation in a cron pipeline</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:09:19 +0000</pubDate>
      <link>https://dev.to/morinaga/what-i-learned-separating-daily-collection-from-weekly-interpretation-in-a-cron-pipeline-45hj</link>
      <guid>https://dev.to/morinaga/what-i-learned-separating-daily-collection-from-weekly-interpretation-in-a-cron-pipeline-45hj</guid>
      <description>&lt;p&gt;The question came from a reasonable place: if market-listening sweeps only run once a week, are meaningful day-over-day changes getting missed? I spent a few hours measuring before answering, and the measurement led to an architectural split I hadn't planned on.&lt;/p&gt;

&lt;p&gt;The answer: collect daily, interpret weekly. Not because weekly is the right cadence for collecting, but because the instrument I'm querying doesn't have daily resolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instrument's resolution problem
&lt;/h2&gt;

&lt;p&gt;YouTube search ranking sounds like a high-resolution signal. A video that gained 10,000 views overnight should move in the results the next day. In practice, the ranking surface is more like a weather map: high-resolution in principle, but what you see at any moment is partly the weather and partly display non-determinism.&lt;/p&gt;

&lt;p&gt;I measured this by fetching the same five search queries, then fetching them again roughly 60 seconds later, and comparing the two result lists using Jaccard similarity. A Jaccard of 1.0 means the lists are identical; 0.0 means no shared entries.&lt;/p&gt;

&lt;p&gt;The same-minute refetch returned Jaccard similarity between &lt;strong&gt;0.43 and 0.88&lt;/strong&gt;. A genuine two-day difference — comparing two days' fetches of the same query — returned &lt;strong&gt;0.03 to 0.58&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The same-minute measurement noise (0.43–0.88) substantially overlaps with the actual two-day signal range (0.03–0.58). When the noise and signal ranges aren't separated, you can't reliably attribute a change to "something changed in the real world" versus "the ranking shuffler surfaced different items." View counts have the same problem: across two consecutive daily fetches, 59% of view counts were byte-identical. The changing ones had mostly crossed a display-rounding threshold ("1.2K" vs "1,247"), not a real change in views.&lt;/p&gt;

&lt;p&gt;Conclusion: this instrument has weekly resolution for persistent trends, not daily resolution. Running the sweep daily doesn't change that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why daily interpretation means publishing noise as strategy
&lt;/h2&gt;

&lt;p&gt;Four downstream systems read the shared market-trends.md file: the YouTube Short generation routine, the Bluesky post queue, the article routine, and the market monitoring sweep. I described &lt;a href="https://dev.to/morinaga/three-things-i-learned-designing-a-shared-context-file-for-automated-routines-1noe"&gt;how that file works as a shared context hub&lt;/a&gt; in detail last week. The Bluesky routine reads its FLAT/CHANGED columns directly into subject selection.&lt;/p&gt;

&lt;p&gt;When market-trends.md was updated daily, single-fetch noise propagated immediately into Bluesky subject selection. A video that appeared in the top-15 because the shuffler surfaced it would become a "trending subject" for the next day's posts. Those posts would go out on a subject with no genuine search demand — just a one-day shuffler artifact.&lt;/p&gt;

&lt;p&gt;The failure was invisible. The posts looked normal, the subjects had the right category, nothing in the logs flagged a problem. The degradation was slow drift toward subjects with no measured demand. This is the category of failure that's hardest to catch: the pipeline runs, the outputs look plausible, and the error accumulates silently. Recognizing the shape of that failure is what the pipeline health monitor pattern is designed to surface — but only if you've defined what "healthy output" means. I hadn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three design rules in the new collection layer
&lt;/h2&gt;

&lt;p&gt;The fix was a dedicated daily collection layer with no interpretation step. &lt;code&gt;scripts/market-listening/collect.mjs&lt;/code&gt; runs at 17:00 UTC every day and writes to &lt;code&gt;data/market-listening/YYYY-MM-DD.json&lt;/code&gt;. Three rules in the code are treated as non-optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: Repeat-and-vote.&lt;/strong&gt; Each query is fetched three times. A video only makes the output if it appears in at least two of the three fetches. The constants are &lt;code&gt;REPS = 3&lt;/code&gt; and &lt;code&gt;MIN_APPEARANCES = 2&lt;/code&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="c1"&gt;// Repeat-and-vote parameters. 3 reps / 2 votes is the cheapest setting that&lt;/span&gt;
&lt;span class="c1"&gt;// removes single-fetch flukes: at Jaccard ~0.6 a genuine top result appears in&lt;/span&gt;
&lt;span class="c1"&gt;// all 3 reps, a shuffler artifact typically in 1.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;MIN_APPEARANCES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a Jaccard of ~0.6, a video that genuinely ranks in the top 15 appears in all three fetches. A shuffler artifact that randomly surfaced once might appear in one fetch, rarely in two, almost never in all three. In live dry-runs before launch, this rule dropped between 16% and 36% of unique videos per query. Over the first full collection run on 2026-08-12, 86 of 287 unique candidate videos were filtered as noise. That's more than a quarter of the raw output — gone before it reached the interpretation layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: Never swallow a failure.&lt;/strong&gt; The previous trends-fetch workflow used a fetch helper that caught HTTP 403 errors and returned an empty array. Two Reddit sources went dead and nobody noticed for 71 days because the collection file kept being written and kept looking plausible. Empty arrays aren't obviously wrong; a file with fewer signals just looks like a quiet week.&lt;/p&gt;

&lt;p&gt;In the new script, every HTTP failure is written to an &lt;code&gt;errors[]&lt;/code&gt; array in the output JSON and the matching entry in &lt;code&gt;sources_ok&lt;/code&gt; is flipped to false. The committed file itself is the alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sources_ok"&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;"youtube"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reddit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autocomplete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"errors"&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;"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;"reddit/game-recs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTTP 403"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-12T17:14:22Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A consumer reading the file sees immediately that reddit-sourced signals are absent. The same principle drives the GitHub Issues monitor — make failures visible in the committed artifact, not just in runner logs that nobody reads daily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3: Parsed JSON only, never raw HTML.&lt;/strong&gt; The initial collection prototype wrote full YouTube search HTML to disk for later parsing. A single full sweep was 15MB. Annualised, that's ~5.5GB of git history for one workflow. The parsed-rows-only format runs ~98KB per day — about 150× smaller. The &lt;a href="https://dev.to/morinaga/five-changes-i-made-after-exhausting-github-actions-free-minutes-twice-5c88"&gt;lessons from managing GitHub Actions artifact storage&lt;/a&gt; apply here: storage costs are real, but reviewability is the bigger reason. A 98KB JSON diff is readable when something goes wrong; a 15MB HTML diff is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interpretation layer stays weekly
&lt;/h2&gt;

&lt;p&gt;The collection script has one explicit comment that appears twice:&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="c1"&gt;// This script never interprets, it only measures.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weekly interpretation sweep reads multiple consecutive days of collection output and looks for two types of signal above the noise floor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;signals.persistent_new&lt;/code&gt;&lt;/strong&gt;: a video that appeared in the top-15 for three consecutive daily collection runs. Persistence across three independent days of REPS=3 collection means this video appeared in 2-of-3 fetches for three days straight — 6 of 9 possible observation slots. That's a much stronger signal than any single-day reading, and it has a measured false-positive rate (the 86/287 noise drop gives an upper bound).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;signals.autocomplete_shift&lt;/code&gt;&lt;/strong&gt;: YouTube's autocomplete suggestions change slowly — on a days-to-weeks timescale rather than minute-to-minute. Day-over-day autocomplete changes are above the noise floor where ranking isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interpretation sweep still uses an LLM — &lt;a href="https://dev.to/morinaga/how-i-coordinate-claude-and-codex-sessions-with-a-pull-based-json-handoff-ledger-5h93"&gt;the same session-coordination pattern from this write-up&lt;/a&gt; — but only once a week, and only when the persistent signals give it something real to reason about. A daily LLM interpretation pass over single-fetch noise would produce outputs that look thoughtful and describe the shuffler.&lt;/p&gt;

&lt;p&gt;The timing was also designed around conflict avoidance. The &lt;a href="https://dev.to/morinaga/four-github-actions-cron-timing-bugs-that-silently-broke-my-daily-pipelines-4935"&gt;auto-tuner lands at 20:42–20:47 UTC in practice&lt;/a&gt;. Collection at 17:00 UTC leaves 3.5 hours of clearance. The &lt;a href="https://dev.to/morinaga/four-github-actions-cron-scheduling-patterns-i-use-in-a-five-workflow-monorepo-3b47"&gt;cron concurrency discipline for this monorepo&lt;/a&gt; uses workflow-level groups, so the time separation matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What downstream consumers gained
&lt;/h2&gt;

&lt;p&gt;Before the split, the market-trends.md file's &lt;code&gt;updated:&lt;/code&gt; date meant "when someone last ran a sweep" — which conflated collection age with interpretation age. A consumer had no reliable way to distinguish stale interpretation from no data.&lt;/p&gt;

&lt;p&gt;After the split, three distinct states are visible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Consumer action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sources_ok: all true&lt;/code&gt; + recent daily commit&lt;/td&gt;
&lt;td&gt;Collection healthy&lt;/td&gt;
&lt;td&gt;Use data normally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sources_ok: youtube=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Source broken&lt;/td&gt;
&lt;td&gt;Use remaining signals, flag gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;updated:&lt;/code&gt; older than 14 days&lt;/td&gt;
&lt;td&gt;Interpretation stale&lt;/td&gt;
&lt;td&gt;Fail-closed to defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The freshness gate &lt;a href="https://dev.to/morinaga/three-things-i-learned-designing-a-shared-context-file-for-automated-routines-1noe"&gt;described in the shared context file write-up&lt;/a&gt; now has sharper semantics because collection freshness (daily commit history) is separate from interpretation freshness (&lt;code&gt;updated:&lt;/code&gt; date). The pipeline health monitor checks &lt;code&gt;sources_ok&lt;/code&gt; flags on every run and would open a GitHub Issue if both YouTube sources failed simultaneously — impossible to detect before the split.&lt;/p&gt;

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

&lt;p&gt;Measure the instrument's day-over-day resolution before designing the collection frequency. The Jaccard measurement took about two hours. I spent a month assuming daily sweeps produced daily-resolution signals because the sweep ran daily. Those are not the same thing.&lt;/p&gt;

&lt;p&gt;The middle ground I considered — keeping daily interpretation but applying a 7-day rolling average to raw scores before writing the file — wouldn't have worked. Averaging noise doesn't produce signal; it produces a smoother representation of noise. The Jaccard non-determinism problem is that individual observations aren't reliably measuring the underlying ranking. Averaging them gives you a smoother non-signal, not a real one.&lt;/p&gt;

&lt;p&gt;The correct insight is that you need consensus across independent samples taken at the same moment (the REPS=3 vote), not smoothing across repeated moments. Voting filters out non-signal at observation time; no amount of post-hoc averaging replaces that.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why 3 fetches specifically?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At Jaccard ~0.6, a genuine top-15 result appears in all three fetches. A shuffler artifact typically appears in one, rarely two, almost never all three. The 2-of-3 threshold is the cheapest configuration that removes single-fetch flukes while keeping collection under 3 minutes (5 queries × 3 reps with 5–15s jitter between hits). REPS=5 would have pushed runtime past 5 minutes and tripled the request count to YouTube's servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's &lt;code&gt;autocomplete_shift&lt;/code&gt; and why is it above the noise floor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;YouTube autocomplete returns suggested completions for seed prefixes ("stardew valley vs...", "best indie game..."). These suggestions update on a days-to-weeks timescale rather than minute-to-minute. When the autocomplete response for a seed changes between two consecutive daily fetches, that indicates genuine search-demand movement — not ranking non-determinism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't the 17:00 UTC cron conflict with other writes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The auto-tuner lands at 20:42–20:47 UTC in practice, giving 3.5 hours of clearance. Earlier attempts at 20:00 UTC caused occasional push conflicts. The collection script's push step includes a 3-attempt retry with &lt;code&gt;git pull --rebase&lt;/code&gt; on conflict — the same pattern used by other write workflows in this monorepo — but time-based separation is simpler than relying on retry logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could you just interpret daily with a longer rolling window?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The problem isn't window length; it's that each individual observation is unreliable at daily resolution. Averaging seven noisy daily fetches gives a smoother curve but each data point still carries Jaccard noise of 0.43–0.88. The vote removes non-signal at observation time. No amount of post-hoc averaging replaces that.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/three-things-i-learned-designing-a-shared-context-file-for-automated-routines-1noe"&gt;Three things I learned designing a shared context file for automated routines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;What I learned building a pipeline health monitor that opens GitHub Issues automatically&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>githubactions</category>
      <category>programming</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Pausing a GitHub Actions cron: the yaml trap that breaks all workflow triggers</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:05:36 +0000</pubDate>
      <link>https://dev.to/morinaga/pausing-a-github-actions-cron-the-yaml-trap-that-breaks-all-workflow-triggers-2nbp</link>
      <guid>https://dev.to/morinaga/pausing-a-github-actions-cron-the-yaml-trap-that-breaks-all-workflow-triggers-2nbp</guid>
      <description>&lt;p&gt;After &lt;a href="https://dev.to/morinaga/how-i-detected-deleted-youtube-videos-using-jsonl-history-diffing-3gjc"&gt;11 uploaded videos disappeared from YouTube overnight&lt;/a&gt;, I wanted to stop the automated upload schedules while I investigated. The fix seemed trivial: comment out the cron lines in two workflow files. I've done this before without thinking about it. Today it broke two unrelated things.&lt;/p&gt;

&lt;p&gt;Here's what went wrong and how to pause a cron correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistake: commenting out the cron line but leaving the key
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;yt-publish.yml&lt;/code&gt; looked like this:&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;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="s1"&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;21&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;1,3,5'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first attempt at pausing was:&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# - cron: '0 21 * * 1,3,5'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leaves &lt;code&gt;schedule:&lt;/code&gt; as a key with no value. In YAML terms, &lt;code&gt;schedule:&lt;/code&gt; with no value is a null scalar, which is valid YAML — but GitHub Actions doesn't accept it. Its workflow schema requires &lt;code&gt;schedule&lt;/code&gt; to be a sequence. An empty &lt;code&gt;schedule:&lt;/code&gt; key fails validation.&lt;/p&gt;

&lt;p&gt;I did the same thing in &lt;code&gt;yt-publish-longform.yml&lt;/code&gt;. Two workflows now in a broken state.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the failure shows up
&lt;/h2&gt;

&lt;p&gt;The cryptic part: the error doesn't say "invalid schedule". GitHub's runner rejects the whole workflow file and the failure appears on every trigger, including push.&lt;/p&gt;

&lt;p&gt;The Actions tab shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run status: failed&lt;/li&gt;
&lt;li&gt;Run duration: &lt;strong&gt;0s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Message: "This run likely failed because of a workflow file issue."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 0s, no job has started. It's a pre-parse failure. Because both affected workflows had a &lt;code&gt;push&lt;/code&gt; trigger as well as &lt;code&gt;schedule&lt;/code&gt;, every commit to main for the next hour showed red checks. The failing workflow was the upload pauser, not any of the actual build or publish workflows — so at first the red commits looked related to something I'd pushed, not to the yaml edit.&lt;/p&gt;

&lt;p&gt;The tell: 0s duration. Any real job failure takes at least a few seconds to allocate a runner. A 0s failure almost always means the workflow file didn't parse.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: comment out the entire schedule key
&lt;/h2&gt;

&lt;p&gt;The correct way to pause a cron without disabling other triggers is to comment out the key itself. &lt;a href="https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#on" rel="noopener noreferrer"&gt;GitHub's workflow syntax docs&lt;/a&gt; require &lt;code&gt;schedule&lt;/code&gt; to be a non-empty sequence — an empty mapping key fails schema validation.&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="c1"&gt;# schedule:   # ⚠ PAUSED 2026-08-11 — uncomment this line AND the cron line below to resume&lt;/span&gt;
  &lt;span class="c1"&gt;#   - cron: '0 21 * * 1,3,5'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;schedule:&lt;/code&gt; itself commented out, the &lt;code&gt;on:&lt;/code&gt; block only contains &lt;code&gt;push&lt;/code&gt;. GitHub parses this without complaint. The push trigger still fires; manual &lt;code&gt;workflow_dispatch&lt;/code&gt; calls still work.&lt;/p&gt;

&lt;p&gt;I also added a note in the comment explaining why it's paused and exactly which line to uncomment. This is worth the extra characters — "uncomment to resume" comments have saved me from re-investigating why a schedule was disabled when I come back to it two weeks later.&lt;/p&gt;




&lt;h2&gt;
  
  
  The useful side effect: workflow_dispatch still works
&lt;/h2&gt;

&lt;p&gt;When only &lt;code&gt;schedule&lt;/code&gt; is disabled, &lt;code&gt;workflow_dispatch&lt;/code&gt; remains active. This turned out to be exactly what I needed: I could test the &lt;a href="https://dev.to/morinaga/how-i-detected-deleted-youtube-videos-using-jsonl-history-diffing-3gjc"&gt;disappearance detection code&lt;/a&gt; by triggering the analytics workflow manually from the Actions tab, without the risk of automatically uploading more videos to a channel with unexplained deletions.&lt;/p&gt;

&lt;p&gt;There's a common pattern where pausing a cron is paired with needing manual testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Something went wrong with the automated run&lt;/li&gt;
&lt;li&gt;You want to stop the automation while you debug&lt;/li&gt;
&lt;li&gt;But you want to run it once manually to verify a fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; covers this exactly. Commenting out &lt;code&gt;schedule&lt;/code&gt; while leaving &lt;code&gt;workflow_dispatch&lt;/code&gt; gives you manual control without re-enabling the automation.&lt;/p&gt;

&lt;p&gt;Relevant to &lt;a href="https://dev.to/morinaga/four-github-actions-cron-timing-bugs-that-silently-broke-my-daily-pipelines-4935"&gt;four cron timing bugs&lt;/a&gt; I've hit before: this is the opposite problem — not a cron that fires at the wrong time, but a cron that needs to stop while preserving manual access. The yaml trap is easy to avoid once you've seen it, but the failure mode is confusing enough the first time that it's worth writing down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;th&gt;What GitHub sees&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Comment out just the cron line&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;schedule:&lt;/code&gt; with null value&lt;/td&gt;
&lt;td&gt;Whole workflow fails (0s) on all triggers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment out the &lt;code&gt;schedule:&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;Valid &lt;code&gt;on:&lt;/code&gt; with only remaining triggers&lt;/td&gt;
&lt;td&gt;Nothing — push and workflow_dispatch still work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove &lt;code&gt;schedule:&lt;/code&gt; key entirely&lt;/td&gt;
&lt;td&gt;Same as above&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second and third rows are equivalent. I prefer commenting over deleting because it preserves the cron expression for when I want to re-enable, and the "PAUSED" comment documents the decision.&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>githubactions</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I detected deleted YouTube videos using JSONL history diffing</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:05:31 +0000</pubDate>
      <link>https://dev.to/morinaga/how-i-detected-deleted-youtube-videos-using-jsonl-history-diffing-3gjc</link>
      <guid>https://dev.to/morinaga/how-i-detected-deleted-youtube-videos-using-jsonl-history-diffing-3gjc</guid>
      <description>&lt;p&gt;The YouTube analytics cron ran without incident on August 10. Health checks were green. Then I looked at the channel manually and counted 79 videos where I expected 89. Ten were gone. A quick oEmbed check on every tracked ID confirmed eleven total deletions — the &lt;a href="https://developers.google.com/youtube/v3/docs/videos/list" rel="noopener noreferrer"&gt;YouTube Data API v3&lt;/a&gt; omitted them entirely, which means deleted rather than unlisted or private.&lt;/p&gt;

&lt;p&gt;One of the eleven was published by this pipeline at 21:47 UTC on August 10. By 01:14 UTC on August 11 it was gone. The pipeline that uploaded it never noticed.&lt;/p&gt;

&lt;p&gt;This article is about the blind spot that caused that, what the implementation looks like, and a subtle bug in the first version that I would have hit the next day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The blind spot: health checks only watch the input side
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl"&gt;pipeline health monitor I built in July&lt;/a&gt; checks whether videos have been queued, generated, and uploaded according to schedule. It opens GitHub Issues when a cron doesn't fire, when the queue goes stale, or when an upload timestamp is missing.&lt;/p&gt;

&lt;p&gt;What it doesn't do — and what I never thought to add — is ask YouTube whether the videos it previously published still exist. The monitor is entirely input-side: local files, local timestamps, queue state. If something on YouTube's end deletes a video, every local artifact still looks valid. The upload timestamp is real. The video ID is in the queue's history. All green.&lt;/p&gt;

&lt;p&gt;This is the same category of problem I wrote about in &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;fixing survivorship bias in my analytics&lt;/a&gt; — a measurement that looks at what you produced, not at the current state of what exists. &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;Silent failure detection&lt;/a&gt; in this pipeline has always been a coverage problem: the tests check output, not outcome.&lt;/p&gt;

&lt;p&gt;The fix needs to work the other direction: take today's live fetch from the YouTube Data API and compare it against what was known to exist yesterday. Anything absent is a candidate deletion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building detect_disappeared() using JSONL history
&lt;/h2&gt;

&lt;p&gt;The analytics script already maintained a JSONL history file — one row per (date, video_id) — because &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;fixing survivorship bias&lt;/a&gt; required having all-time fleet data rather than just the current top/bottom snapshot. That existing history is the comparison baseline.&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;previous_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history_path&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="n"&gt;HISTORY_PATH&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;tuple&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="nb"&gt;dict&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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(date, {video_id: row}) of the most recent day already in history.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;by_date&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="nb"&gt;str&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="nb"&gt;str&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;history_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history_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="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&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;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disappeared&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;by_date&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strftime&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-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_date&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prior&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;by_date&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prior&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prior&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;detect_disappeared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats_videos&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;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;history_path&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="n"&gt;HISTORY_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;prev_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;previous_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;prev_rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stats_videos&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prev_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;published_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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;published_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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;views&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;live&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;stats_videos&lt;/code&gt; is the live fetch from &lt;code&gt;videos.list&lt;/code&gt; — everything the Data API returns for the channel today. &lt;code&gt;previous_snapshot()&lt;/code&gt; reads the most recent date already written to history (not today). The diff is a set subtraction: IDs in the snapshot that are absent from the live set.&lt;/p&gt;

&lt;p&gt;The result goes two places. First, the run prints a &lt;code&gt;::warning::&lt;/code&gt; annotation that surfaces in the GitHub Actions step summary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;::warning::11 video(s) disappeared from YouTube since 2026-08-10: abc123(24v), def456(0v), ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the daily report gains a "Disappeared from YouTube" table with each ID, its last-known view count, and its publish date. Both outputs are loud by design — this is the one failure mode the input-side health checks structurally cannot catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  The subtle bug: disappearance markers that look like presence
&lt;/h2&gt;

&lt;p&gt;The first implementation had a defect I would have hit on the very next run.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;detect_disappeared()&lt;/code&gt; finds deletions, they get appended to the history file as rows with &lt;code&gt;status: "disappeared"&lt;/code&gt;. That keeps a permanent record of the deletion and lets &lt;code&gt;day7_by_archetype()&lt;/code&gt; exclude those videos from median calculations.&lt;/p&gt;

&lt;p&gt;The bug: &lt;code&gt;previous_snapshot()&lt;/code&gt; read every row in the history file to build the "what existed yesterday" map. A &lt;code&gt;status: "disappeared"&lt;/code&gt; row has a valid &lt;code&gt;date&lt;/code&gt; and &lt;code&gt;video_id&lt;/code&gt; — so it was being counted as "this video was present on that date." The next day's run would read the disappeared marker for a video, conclude that video was present yesterday, not find it in the live fetch (because it was deleted), and report it as disappeared again. The same 11 videos would fire every single day with a drifting &lt;code&gt;last_seen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is one filter condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&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;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disappeared&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;by_date&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disappeared markers record an absence, not a presence. Including them when building the comparison snapshot inverts their meaning. The test that covers this:&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;test_disappearance_is_reported_once_not_every_day&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Day 1: three alive. Day 2: one vanishes and is marked.
&lt;/span&gt;    &lt;span class="c1"&gt;# Day 3: the marker must not read as "present on day 2" and re-fire.
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect_disappeared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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 built this test after the fix rather than before, but it locks in the correct semantics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Excluding deleted videos from archetype medians
&lt;/h2&gt;

&lt;p&gt;A secondary issue: if a deleted video's &lt;code&gt;status: "disappeared"&lt;/code&gt; row were included in the day-7 median calculation, it would drag medians down. The 11 videos that were deleted all sat at 0-25 views. A &lt;code&gt;product_findindiegame&lt;/code&gt; Short at 12 views included in the archetype's day-7 median would look like a weak performer, when actually it never had a chance to accumulate views before disappearing.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;survivorship bias in the reverse direction&lt;/a&gt;: not over-counting winners by excluding losers, but over-penalizing the archetype by including phantom underperformers.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;day7_by_archetype()&lt;/code&gt; already has a guard for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disappeared&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# deleted from YouTube — must not count toward any median
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comparison pipeline now looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data path&lt;/th&gt;
&lt;th&gt;What it tracks&lt;/th&gt;
&lt;th&gt;Disappeared handling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_uploads()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local upload records (timestamps, IDs)&lt;/td&gt;
&lt;td&gt;Unaware of YouTube deletions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_stats()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live YouTube Data API response&lt;/td&gt;
&lt;td&gt;Only shows surviving videos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;append_history()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSONL per (date, video_id)&lt;/td&gt;
&lt;td&gt;Records &lt;code&gt;status: "disappeared"&lt;/code&gt; rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detect_disappeared()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Diffs live vs snapshot&lt;/td&gt;
&lt;td&gt;Fires :⚠️: and report table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;day7_by_archetype()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Day-7 view medians by archetype&lt;/td&gt;
&lt;td&gt;Skips &lt;code&gt;status: "disappeared"&lt;/code&gt; rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I'd change about the original design
&lt;/h2&gt;

&lt;p&gt;Adding output-side monitoring from day one would have caught this. The simplest form is a daily count check: fetch &lt;code&gt;channel.statistics.videoCount&lt;/code&gt; from the Data API and compare it against your own count of known-uploaded IDs. A drop of more than 1-2 between runs is an anomaly worth alerting on.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/morinaga/how-i-moved-ai-video-archetype-selection-from-prose-to-a-code-owned-daily-directive-11np"&gt;auto-tuner's archetype directive system&lt;/a&gt; runs daily and produces a directive that the video generation routine consumes. That directive is built on the day-7 median — so a corrupted median directly produces a wrong archetype bias. I'd caught one &lt;a href="https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi"&gt;survivorship-bias version of this problem&lt;/a&gt; two days earlier. This was the inverse arriving from an unexpected direction.&lt;/p&gt;

&lt;p&gt;Input-side health checks give you confidence that your automation ran correctly. Output-side checks give you confidence that what you produced still exists. For a publishing pipeline, you need both. The &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;pipeline health checks&lt;/a&gt; I had were comprehensive on the input side and completely blind on the output side. That's the structural gap this detection closes.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Does this distinguish between deleted and unlisted/private?&lt;/strong&gt;&lt;br&gt;
In this case, yes — the YouTube Data API omits videos entirely when they're deleted. Private videos owned by the authenticated account appear in response if you're using OAuth; public videos that go private return no results on a public key request, same as deleted. For this pipeline the distinction didn't matter: all 11 were confirmed deleted via oEmbed returning 404 and the Data API omitting them with a public key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the API itself has an outage and returns fewer videos?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;detect_disappeared()&lt;/code&gt; doesn't distinguish between a partial API response and a real deletion. If the API returns 60 of 79 videos, 19 will be flagged as disappeared. For now I treat this as an acceptable false positive rate — the :⚠️: annotation requires manual confirmation, and a full-channel API outage is obvious from the report. Adding a sanity check on the returned video count (is it within 10% of yesterday's) would reduce noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should the disappeared rows ever be pruned from history?&lt;/strong&gt;&lt;br&gt;
Not for now. They're useful as an audit trail and the file is small. If the history grows past a few MB, pruning rows older than 90 days while keeping disappeared markers would be the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened with the 11 deleted videos?&lt;/strong&gt;&lt;br&gt;
Both publish schedules are paused pending a YouTube Studio check for a policy notice. The pipeline never received one. The channel itself is healthy (public, 79 videos). I don't have a confirmed explanation yet — I'll post an update when I do.&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>python</category>
      <category>programming</category>
      <category>showdev</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Three things I learned designing a shared context file for automated routines</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Tue, 11 Aug 2026 06:45:14 +0000</pubDate>
      <link>https://dev.to/morinaga/three-things-i-learned-designing-a-shared-context-file-for-automated-routines-1noe</link>
      <guid>https://dev.to/morinaga/three-things-i-learned-designing-a-shared-context-file-for-automated-routines-1noe</guid>
      <description>&lt;p&gt;I run four &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; cron routines that all need to know the same thing: which game subjects have live search demand right now. The YouTube Short generation routine picks matchup titles from it. The Bluesky post queue uses it to weight the gaming slot. The article generation routine uses it to anchor VoC topics. A market monitoring sweep updates it.&lt;/p&gt;

&lt;p&gt;For the first month of this setup, each routine had its own baked-in assumptions. The YT script used a hardcoded list of "safe" matchup pairs. The Bluesky routine had a separate file with a different classification of "hot" subjects. The article routine ignored market signal entirely. Every system was drifting independently, and updating any one of them required touching multiple files.&lt;/p&gt;

&lt;p&gt;The fix was one shared markdown file — &lt;code&gt;docs/market-trends.md&lt;/code&gt; — with three design rules that I got progressively more right over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: counted numbers only, every claim traceable
&lt;/h2&gt;

&lt;p&gt;The first version of this file was just notes. "Underdog narratives seem to do well." "Players talk about price gaps." Useful to write down, useless to automate against. A routine reading that file has no way to prefer one subject over another when the file's evidence is impressionistic.&lt;/p&gt;

&lt;p&gt;The TERUS method — every number is a count from a specific source, every source is saved raw — forced the file into something a routine can act on. "3.1M views, 'Indie Dev Beat AAA Studios With 0 Marketing'" is something a routine can compare against "5K views, most recent build-in-public short." An impression is not.&lt;/p&gt;

&lt;p&gt;This also matters for the author. When you have to write a specific count, you have to actually count. You can't hand-wave "players care about X" when the claim has to be backed by "I read 43 comments and 11 mentioned X." The discipline of traceable numbers improved the file's accuracy more than any review process would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: fail-closed freshness gate
&lt;/h2&gt;

&lt;p&gt;The second version of the file had a format problem: it could be months old and none of the consumers would know. The YT script would anchor on "hot subjects" that had been stale for two months, and the routine would behave as if the data were current.&lt;/p&gt;

&lt;p&gt;The fix was a &lt;code&gt;updated:&lt;/code&gt; date field at the top of the file and a freshness rule that consumers must check before using the file's content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;updated: 2026-08-10

Freshness rule (fail-closed): if updated: is more than 14 days
old, consumers MUST ignore this file and fall back to their own defaults —
stale trends are worse than no trends.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail-closed here means: if the file is stale, the routine ignores the market-trends data and uses conservative defaults (established evergreen matchups, not trend-chasing). It doesn't silently use the stale data, and it doesn't crash. The 14-day window matches the social-listening sweep cadence — I update the file weekly when things are moving, less often when they're stable.&lt;/p&gt;

&lt;p&gt;The key design choice is making freshness the consumer's responsibility rather than the publisher's. The consumer checks the &lt;code&gt;updated:&lt;/code&gt; date on every run. This means a routine added six months from now will inherit the freshness check automatically if it follows the documented pattern — I don't need to update a registry or notify consumers when the file goes stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: separate what's measured from what's prescribed
&lt;/h2&gt;

&lt;p&gt;The third version made the biggest difference: splitting the file into an observations section (what I measured) and a prescriptions section (what consumers should do with it).&lt;/p&gt;

&lt;p&gt;The original file mixed these together: "Underdog narratives get high views, so use them." That forces consumers to interpret the claim, and different routines interpret it differently. The Bluesky routine might take "use underdog narratives" to mean write underdog captions. The YT routine might take it to mean use underdog-narrative titles. Neither is wrong, but they diverge over time.&lt;/p&gt;

&lt;p&gt;The current structure separates the measurement table from the action directive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Observations section&lt;/strong&gt;: the raw data table — video format, measured view counts, evidence notes. This is what I actually found. Consumers can read this to understand the evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot subjects list&lt;/strong&gt;: the prescriptions — specific matchup subjects, anchor candidates, and what-to-avoid notes. This is what the routine should act on. The YT script reads this section and picks from the "anchor OK" rows; the Bluesky routine reads it to weight game subjects for the 70% gaming slot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The separation means I can update the observations without changing any consuming routine's behavior. If I notice something new in the measurement data, I can add it to the observations section without touching the prescriptions — the prescriptions only change when I've decided what action to take based on the evidence.&lt;/p&gt;

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

&lt;p&gt;Build the freshness check first. I spent a month using stale market data because it felt like a future concern. It wasn't — the first time I updated the file and forgot to check whether the routines were actually reading the new version, I lost a week of YT anchor selection to outdated data.&lt;/p&gt;

&lt;p&gt;The other thing: write the consuming routine's expected behavior in the file itself. &lt;code&gt;docs/market-trends.md&lt;/code&gt; now has a "Who reads this (mandatory)" section that names each consumer and what it should do with the file. When I added the article routine as a fourth consumer, I had a clear template for how to wire it in — and a record of what the existing consumers were doing, so I could check for overlap or contradiction.&lt;/p&gt;

&lt;p&gt;A shared markdown file with a freshness gate and a traced-evidence discipline is a long way from a config service, but for a four-system automation project running on a $25/month budget, it's the right level of infrastructure.&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>showdev</category>
      <category>programming</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>How I fixed survivorship bias in my YouTube analytics by logging all videos</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Tue, 11 Aug 2026 06:45:09 +0000</pubDate>
      <link>https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi</link>
      <guid>https://dev.to/morinaga/how-i-fixed-survivorship-bias-in-my-youtube-analytics-by-logging-all-videos-3cbi</guid>
      <description>&lt;p&gt;My YouTube analytics script ran for three months and reported two tables every day: Top 5 videos by views-per-day and Bottom 5. I used those tables to decide which video archetypes to produce the next day. It seemed reasonable — high performers tell you what's working, low performers tell you what isn't.&lt;/p&gt;

&lt;p&gt;The problem, which I finally wrote down explicitly during &lt;a href="https://dev.to/morinaga/how-i-moved-ai-video-archetype-selection-from-prose-to-a-code-owned-daily-directive-11np"&gt;a directive audit this week&lt;/a&gt;, is that Top5/Bottom5 is exactly the wrong sample for comparing archetypes. You're always selecting on the outcome. That means you can't compute a real median for any archetype, can't tell whether a typical video of type A outperforms a typical video of type B, and can't run any form of hook A/B experiment. All you can say is which videos happen to be at the distribution's edges on a given day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The original report and why it felt informative
&lt;/h2&gt;

&lt;p&gt;The daily cron fetched all video stats from the &lt;a href="https://developers.google.com/youtube/v3/docs/videos/list" rel="noopener noreferrer"&gt;YouTube Data API v3&lt;/a&gt;, computed views-per-day for each video older than 24 hours, sorted by that rate, and wrote the top 5 and bottom 5 into a markdown report in &lt;code&gt;docs/yt-analytics/&lt;/code&gt;. The script then read those tables to build a "prefer X, avoid Y" bias hint for the next day's video generation directive.&lt;/p&gt;

&lt;p&gt;This caught obvious failures. The &lt;code&gt;build_in_public&lt;/code&gt; archetype really did crater — &lt;a href="https://dev.to/morinaga/three-archetype-signals-the-youtube-analytics-auto-tuner-surfaced-after-two-weeks-2ebk"&gt;I could see it collapsing from a median around 34 views-per-day to around 8&lt;/a&gt; over about three weeks. But obvious failures are easy. The harder question is: among archetypes that aren't catastrophically bad, which one has a better median? That's exactly what Top5/Bottom5 cannot answer.&lt;/p&gt;

&lt;p&gt;Here's why. If &lt;code&gt;product_findindiegame&lt;/code&gt; is the dominant archetype — which it is, the directive has favored it for weeks — then it will appear more often in both tails just because there are more of them. A Top5 table with three findindiegame entries and two unknowns tells me nothing about whether findindiegame is better than, say, &lt;code&gt;ossfind&lt;/code&gt;. I need the middle of the distribution, not the edges.&lt;/p&gt;

&lt;p&gt;The tables are also silent about new archetypes with only 2-3 videos. Those videos rarely hit Top5 or Bottom5 because neither cluster is large enough to dominate. I could run a new archetype for a month and have no data on it at all from this report.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a valid comparison requires
&lt;/h2&gt;

&lt;p&gt;To compare archetypes fairly, I need three things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What you miss&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top5/Bottom5 daily&lt;/td&gt;
&lt;td&gt;Extreme performers&lt;/td&gt;
&lt;td&gt;Median distribution&lt;/td&gt;
&lt;td&gt;Catching catastrophic failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;views/day, all videos&lt;/td&gt;
&lt;td&gt;Current rate, full fleet&lt;/td&gt;
&lt;td&gt;Age confound — a 2-day Short looks worse than a 20-day one&lt;/td&gt;
&lt;td&gt;Quick daily snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-fleet JSONL + day-7&lt;/td&gt;
&lt;td&gt;All videos at age ~7 days&lt;/td&gt;
&lt;td&gt;Nothing — full population&lt;/td&gt;
&lt;td&gt;Archetype A/B, hook arm tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The age problem matters: YouTube Shorts plateau fast. A fresh video has a high views-per-day because its 24-hour velocity is being divided by 1 or 2 days. A 30-day-old video has a low rate even if it accumulated more total views. Comparing views-per-day across videos of different ages is structurally biased toward new content.&lt;/p&gt;

&lt;p&gt;Age-controlled: observe every video at the same point in its lifecycle. Day 7 is a good choice because Shorts on this channel reach roughly 99% of their lifetime views by that point — the day-7 number is a close proxy for total views, without the age confound.&lt;/p&gt;

&lt;p&gt;To get day-7 observations, I need a snapshot of every video's view count taken seven days after it was published — which means logging all videos daily so I can find the snapshot closest to day 7 in post.&lt;/p&gt;




&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;The new &lt;code&gt;append_history()&lt;/code&gt; function in &lt;code&gt;scripts/yt-analytics/run.py&lt;/code&gt; does this logging:&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;append_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats_videos&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;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;history_path&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="n"&gt;HISTORY_PATH&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strftime&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-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seen_today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;history_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history_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="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;seen_today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stats_videos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;vid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen_today&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&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;statistics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&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;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;rows&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publishedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;archetype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&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;_archetype&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;unknown&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;hook_arm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&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;_hook_arm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;views&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_stat_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;viewCount&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;likes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_stat_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;likeCount&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;comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_stat_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;commentCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;history_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;history_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Idempotent per &lt;code&gt;(date, video_id)&lt;/code&gt; — if the cron runs twice the same day, the second run writes nothing. One pass over the file to build &lt;code&gt;seen_today&lt;/code&gt;, then append. No extra API quota: the full channel fetch was already happening; I just stopped discarding the non-tail rows.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;day7_by_archetype()&lt;/code&gt; function reads the history and finds each video's closest snapshot in the 6-to-8-day window:&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;day7_by_archetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history_path&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="n"&gt;HISTORY_PATH&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;best&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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="c1"&gt;# vid -&amp;gt; (dist_from_7, views, archetype)
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history_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="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;published_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cur&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="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&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;views&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="n"&gt;d&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;archetype&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;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;by_arch&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="nb"&gt;str&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;int&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_dist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;by_arch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;arch&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="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vs&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_arch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 6-8 day window matters because the cron doesn't run at a precise offset from video publish time. A video might get its first post-launch observation at age 6.2 days or 7.8 days depending on publish time vs cron schedule. Taking the closest observation in that window gets the most age-accurate reading without requiring exact timing.&lt;/p&gt;




&lt;h2&gt;
  
  
  When the new metric takes over
&lt;/h2&gt;

&lt;p&gt;Day-7 data is only trustworthy once there are enough samples per archetype. Two videos is not enough to compute a meaningful median:&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;DAY7_MIN_PER_ARCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;   &lt;span class="c1"&gt;# minimum observations per archetype
&lt;/span&gt;&lt;span class="n"&gt;DAY7_MIN_ARCHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;     &lt;span class="c1"&gt;# minimum archetypes meeting that threshold
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until both conditions are met, &lt;code&gt;strategy_ranking()&lt;/code&gt; falls back to the old views/day bias — which still has the age confound, but is less wrong than trusting a 1-2 sample day-7 median. The daily report shows which metric drove the decision, so I can see when the day-7 path has enough data to activate.&lt;/p&gt;

&lt;p&gt;This is the same pattern as the &lt;a href="https://dev.to/morinaga/how-a-frozenset-guard-ended-a-youtube-directive-self-contradiction-52e2"&gt;frozenset guard that fixed the directive self-contradiction&lt;/a&gt;: use a known-good default until the data is genuinely ready, fail closed rather than making a decision with noise. A too-eager switch to the new metric would have replaced one bias (age) with another (sparse sample noise).&lt;/p&gt;




&lt;h2&gt;
  
  
  What this unlocks: the hook A/B experiment
&lt;/h2&gt;

&lt;p&gt;The survivorship bias fix was a prerequisite for the hook A/B experiment, not an end in itself. The market sweep I ran (see &lt;code&gt;docs/market-trends.md&lt;/code&gt;, updated 2026-08-10) found that the top-40 videos in this niche are almost all story-first or emotion-first titles — none of the niche's 1M+ performers lead with a data point. But that's a survivor-biased observation: maybe data-first hooks just fail and get filtered out before they accumulate enough views to appear in the sample. I can't tell from looking at the winners.&lt;/p&gt;

&lt;p&gt;So I'm running a proper A/B split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Even UTC day = CONTROL&lt;/strong&gt;: number-first hook, as the original R7 rule specified&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Odd UTC day = MARKET VARIANT&lt;/strong&gt;: story/underdog-emotion hook (review count data cited inside the script as proof, not as the title's lead)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each video in the generation queue now records &lt;code&gt;"hook_arm": "control"&lt;/code&gt; or &lt;code&gt;"hook_arm": "variant"&lt;/code&gt; at generation time. &lt;code&gt;append_history()&lt;/code&gt; writes that field to the JSONL. After ≥20 videos per arm accumulate a day-7 observation, the ranking comparison between arms will be possible.&lt;/p&gt;

&lt;p&gt;Without full-fleet JSONL, this A/B test produces no result. Top5/Bottom5 gives you too few samples per arm per period, and they're censored samples at that. The &lt;a href="https://dev.to/morinaga/three-approaches-i-use-to-catch-silent-failures-in-a-cron-heavy-github-actions-pipeline-351j"&gt;three approaches I use for silent failure detection&lt;/a&gt; also apply here: the cron now emits a &lt;code&gt;history: appended N rows&lt;/code&gt; line that monitoring can check against zero to catch days where the full-fleet fetch silently returned nothing.&lt;/p&gt;




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

&lt;p&gt;Start the JSONL on day one of the channel. I have approximately three months of daily analytics runs where only the top and bottom five videos made it to disk. That data is unrecoverable — the YouTube Data API does not expose historical view counts at past timestamps. I know that &lt;code&gt;product_findindiegame&lt;/code&gt; outperformed &lt;code&gt;build_in_public&lt;/code&gt; by roughly 4x in that period based on the Top5/Bottom5 tables, but I can't get the full distribution shape or the hook-arm breakdown because I didn't log it.&lt;/p&gt;

&lt;p&gt;The other thing I'd do differently is index the JSONL into a proper database from the start. The current implementation reads the entire history file on every run to build &lt;code&gt;seen_today&lt;/code&gt;. It's fast at the current scale — a few hundred rows — but a growing channel would slow this down linearly. &lt;a href="https://dev.to/morinaga/three-ways-tursos-free-tier-limits-shaped-my-directory-site-data-model-2cjo"&gt;Turso's free-tier limits shaped my data model&lt;/a&gt; for the directory sites in a similar way; I chose JSONL here because it's zero-config and the channel is small, but a proper table indexed on &lt;code&gt;(video_id, date)&lt;/code&gt; would make the day-7 window query O(log n) instead of O(n).&lt;/p&gt;

&lt;p&gt;The deeper lesson is about what "informative" means in a report. Top5/Bottom5 is informative if your goal is to catch extreme failures quickly. It's not informative if your goal is to compare population medians. Those are different goals and require different data collection strategies. I conflated them for three months because the report looked like it was answering the question I cared about.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Why day 7 specifically, and not day 14 or day 30?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Shorts on this channel plateau fast. The view accumulation curve is close to flat by day 5-6 for most videos; day 7 is safely past the plateau for nearly all of them. Using day 14 or day 30 would require waiting longer for the data to mature, and for the A/B experiment to produce a usable result with 20+ samples per arm, that could add 2-3 extra weeks of delay. Day 7 captures most of the signal with the shortest wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the 6-8 day window cause measurement noise?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some. A day-6 snapshot will be slightly lower than a day-7 snapshot and a day-8 snapshot slightly higher. The window exists because exact-day-7 matching would miss videos where the cron ran before the 7-day mark. In practice the difference between a day-6 and day-8 reading is small relative to the difference between archetype medians — the noise is real but not dominant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to videos I published before the JSONL started?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They don't get a day-7 observation. The &lt;code&gt;day7_by_archetype()&lt;/code&gt; function only returns videos with a snapshot in the 6-8 day window. Pre-JSONL videos won't appear in the archetype rankings. That's correct behavior — I don't want to impute or fabricate historical data. The system starts clean from the first JSONL entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When does the day-7 metric replace views/day in strategy decisions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;DAY7_MIN_ARCHES&lt;/code&gt; (currently 2) archetypes each have at least &lt;code&gt;DAY7_MIN_PER_ARCH&lt;/code&gt; (currently 3) day-7 observations. Both thresholds must be met simultaneously. The daily report shows the metric label that drove the decision, so it's transparent when the switchover happened.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/how-i-moved-ai-video-archetype-selection-from-prose-to-a-code-owned-daily-directive-11np"&gt;How I moved archetype selection from prose to a code-owned directive&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/three-things-view-count-data-forced-me-to-change-in-my-youtube-game-comparison-titles-jif"&gt;Three view-count data lessons from YouTube game comparison titles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/morinaga/what-i-learned-adding-jaccard-duplicate-detection-to-a-youtube-shorts-spec-audit-58he"&gt;What I learned adding Jaccard duplicate detection to a spec audit&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>python</category>
      <category>programming</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three public HTTP APIs I read daily without registering for a key</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:06:01 +0000</pubDate>
      <link>https://dev.to/morinaga/three-public-http-apis-i-read-daily-without-registering-for-a-key-1aid</link>
      <guid>https://dev.to/morinaga/three-public-http-apis-i-read-daily-without-registering-for-a-key-1aid</guid>
      <description>&lt;p&gt;My daily trends fetch is a Node.js script that runs in GitHub Actions, hits three APIs, and writes a JSON file that feeds my X-drafts pipeline later in the day. None of the three sources require an API key. No OAuth flow, no dashboard signup, no rate-limit token to rotate.&lt;/p&gt;

&lt;p&gt;That sounds trivial, but it matters for CI pipelines. Every API key stored in GitHub Secrets is a secret that can expire, a secret that has to be rotated when a team member leaves, a secret that creates a failure surface. Keyless reads eliminate all of that for the sources where it's possible.&lt;/p&gt;

&lt;p&gt;Here are the three I use and the practical limits of each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hacker News Firebase API
&lt;/h2&gt;

&lt;p&gt;The HN API is &lt;a href="https://github.com/HackerNews/API" rel="noopener noreferrer"&gt;publicly documented on GitHub&lt;/a&gt; and hosted on Firebase. No key, no auth header, no rate limit published in the official docs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://hacker-news.firebaseio.com/v0/topstories.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;fetchJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://hacker-news.firebaseio.com/v0/item/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&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;This returns the top story IDs, then fetches each item. The item objects include &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;score&lt;/code&gt;, and &lt;code&gt;descendants&lt;/code&gt; (comment count). For my use case — grabbing the top 20 stories to identify what's trending in dev — that's everything I need.&lt;/p&gt;

&lt;p&gt;The practical limit is latency. Fetching 20 items individually over Firebase takes 2–4 seconds depending on cold-start behavior. Parallelizing with &lt;code&gt;Promise.all&lt;/code&gt; handles this fine in a script context. In a browser context, sequential fetches would be painful.&lt;/p&gt;

&lt;p&gt;Firebase occasionally throttles aggressively for a few minutes if you hit it from many IPs in a short window. In four months of daily runs from GitHub Actions I've hit this once. The fix was a 500ms delay between item fetches, which is enough to avoid triggering it in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  dev.to public API
&lt;/h2&gt;

&lt;p&gt;dev.to exposes a &lt;a href="https://developers.forem.com/api/v1#tag/articles/operation/getArticles" rel="noopener noreferrer"&gt;read-only articles endpoint&lt;/a&gt; that works without authentication for public content. The &lt;code&gt;top=1&lt;/code&gt; parameter returns articles sorted by recent reaction count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://dev.to/api/articles?per_page=12&amp;amp;top=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;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;public_reactions_count&lt;/code&gt;, &lt;code&gt;comments_count&lt;/code&gt;, and &lt;code&gt;tag_list&lt;/code&gt; on each article. The &lt;code&gt;catch(() =&amp;gt; [])&lt;/code&gt; is load-bearing: dev.to's API returns 503 occasionally, and swallowing that to an empty array lets the rest of the trends fetch continue.&lt;/p&gt;

&lt;p&gt;One thing that surprised me: &lt;code&gt;top=1&lt;/code&gt; doesn't mean "top from the last 1 day". The documentation is ambiguous about the time window. In practice, articles from the previous two to three days appear in the results. For trending detection that's fine; for building a "published today" feed it would be wrong.&lt;/p&gt;

&lt;p&gt;The API does not require auth for reading public articles. It has a rate limit — 10 requests per second per IP, per the docs — which I've never come close to hitting with a once-daily run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reddit .json endpoints
&lt;/h2&gt;

&lt;p&gt;Reddit's most underused feature is the &lt;code&gt;.json&lt;/code&gt; suffix that works on almost any listing URL. Append &lt;code&gt;.json&lt;/code&gt; to a subreddit URL and you get the full listing data without OAuth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.reddit.com/r/programming/top.json?t=day&amp;amp;limit=10&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response structure is &lt;code&gt;{data: {children: [{data: {title, url, score, ...}}]}}&lt;/code&gt;. Each child has &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;score&lt;/code&gt;, &lt;code&gt;num_comments&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, and &lt;code&gt;selftext&lt;/code&gt; (body for self-posts).&lt;/p&gt;

&lt;p&gt;The User-Agent header matters here. Reddit blocks requests without a user agent string, and returns 429 for user agents that look like bots making too many rapid requests. I use &lt;code&gt;my-app/1.0&lt;/code&gt; as the UA string; that's been stable across months of daily runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-app/1.0&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reddit's &lt;code&gt;.json&lt;/code&gt; endpoint does not require an API key for public subreddit content. Accessing private subreddits or performing writes requires OAuth. For the reading use case — grabbing the top posts from r/programming and r/SideProject — no registration is needed.&lt;/p&gt;

&lt;p&gt;The limit I hit in practice: the &lt;code&gt;t=day&lt;/code&gt; (top of the day) filter doesn't always return 10 results early in UTC because the day is new and few posts have accumulated significant score yet. Running at 06:00 UTC I sometimes get 3–4 results instead of 10. Running at 22:00 UTC I consistently get the full batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do with the combined output
&lt;/h2&gt;

&lt;p&gt;The script writes a single JSON file to &lt;code&gt;content/trends/YYYY-MM-DD.json&lt;/code&gt; with all three sources merged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;hn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dev.to&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;reddit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`r/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;null&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A later step in the same day's workflow reads this file and uses it to draft X posts via Claude. The trends file is the handoff artifact between the data-collection step (no API key, runs first) and the generation step (requires Anthropic API key, runs second).&lt;/p&gt;

&lt;p&gt;That separation also means I can test the collection step in isolation without spending any API budget. The trends fetch fails silently on individual source errors — Reddit 503, HN throttle, dev.to rate limit — and still writes a file with whatever it managed to collect. Downstream steps get degraded input rather than a broken run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the keyless approach breaks down
&lt;/h2&gt;

&lt;p&gt;Not every useful data source has a public read endpoint. GitHub's search API requires auth past 60 requests per hour. HuggingFace's models API requires a token for private repos but is keyless for public models — I use the keyless endpoint for that too, but it's limited to what HF considers "public". Steam's storefront API has undocumented endpoints that work without auth, though the &lt;a href="https://dev.to/morinaga/four-undocumented-steam-store-api-behaviors-every-game-directory-builder-should-know-3f63"&gt;behaviors are inconsistent enough&lt;/a&gt; that I treat them carefully.&lt;/p&gt;

&lt;p&gt;The three sources above — HN, dev.to, Reddit .json — have been stable for the four months I've used them. That's not a guarantee of permanence. Reddit has changed its API policies before; dev.to's read rate limits could tighten. The fallback behavior (catch to empty array) means the pipeline degrades gracefully rather than breaking when that happens.&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>javascript</category>
      <category>programming</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Notable this week: WeatherNext, Oracle OpenJDK AI ban, DeepMind reshuffle, Shieldstral</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:05:57 +0000</pubDate>
      <link>https://dev.to/morinaga/notable-this-week-weathernext-oracle-openjdk-ai-ban-deepmind-reshuffle-shieldstral-57o6</link>
      <guid>https://dev.to/morinaga/notable-this-week-weathernext-oracle-openjdk-ai-ban-deepmind-reshuffle-shieldstral-57o6</guid>
      <description>&lt;p&gt;Five things I bookmarked this week. They span a range — a climate AI model, an open-source policy move, a lab restructuring, an operational scraper account, and a new small moderation model. None of these appeared in last Saturday's notable releases post, which focused on agent frameworks and frontier models.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Google DeepMind restructuring: Demis Hassabis to Chair, Jeff Dean departs
&lt;/h2&gt;

&lt;p&gt;HN score: 366, 494 comments on August 5 — one of the higher-comment threads of the week. Google announced that Demis Hassabis moves from CEO to Chair of Google DeepMind, and Jeff Dean is departing. The announcement came through Google's official blog. These transitions at frontier research labs tend to shift what gets prioritized over the following 12-18 months. Moving a founder to Chair often signals a pivot toward product or commercialization under new leadership — which says something about where DeepMind sits in Google's roadmap. I'm watching whether this changes DeepMind's stance on open weights; that's been a point of differentiation between DeepMind and the pure commercial labs, and it tends to get re-litigated when leadership changes. Source: &lt;a href="https://blog.google/company-news/inside-google/message-ceo/next-chapter-ai-momentum/" rel="noopener noreferrer"&gt;Google blog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. DeepMind WeatherNext: breakthrough in cyclone forecasting
&lt;/h2&gt;

&lt;p&gt;Score: 341, 106 comments on August 8. WeatherNext is DeepMind's new weather forecasting model with announced improvements on cyclone trajectory prediction. The specific framing — breakthrough on severe weather events rather than general forecast accuracy — is meaningful because cyclone prediction errors have historically had direct emergency-response consequences. I don't have a clear view yet on how it compares to ECMWF operationally; that comparison matters and I'll revisit once there's independent benchmarking. What seems defensible now: AI weather models crossing from "research interesting" to "potentially useful for emergency response" is a distinct capability threshold from the frontier LLM race, and worth tracking separately. Source: &lt;a href="https://deepmind.google/blog/weathernext-ai-model-achieves-breakthrough-in-forecasting-cyclones/" rel="noopener noreferrer"&gt;DeepMind blog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Oracle bans AI-generated code from OpenJDK
&lt;/h2&gt;

&lt;p&gt;Score: 317, 225 comments on August 7. Oracle prohibited AI-generated code contributions from being merged into OpenJDK, despite Larry Ellison's public claim that Oracle isn't writing its own code. The stated rationale is copyright uncertainty — the same underlying concern that made the Linux kernel's maintainers cautious. What's notable is the stance: Oracle is drawing a harder line than most major open-source projects, where disclosure requirements rather than outright bans have been the working consensus. Whether that reflects legitimate copyright concerns or something competitive is genuinely unclear to me. The operational consequence is interesting regardless: this forces contributors to prove code origin in ways that nobody has a reliable method for yet, and it will come up in every downstream OSS project that imports from OpenJDK. Source: &lt;a href="https://app.dealroom.co/news/feed/oracle-bans-ai-generated-code-from-openjdk-despite-ellison-s-claim-oracle-isn-t-writing-its-own-code" rel="noopener noreferrer"&gt;Dealroom&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A year of fighting scrapers on a 1.5-million-page website
&lt;/h2&gt;

&lt;p&gt;Score: 345, 405 comments on August 7 — the highest-comment thread I saw in this week's snapshot. The patronview.com post is a detailed operational account of what bot traffic looks like at scale: 99% non-human, the economics of blocking tools, what countermeasures work, and what doesn't. Running three programmatic sites with far fewer pages, I still need to plan for this. The finding I'm holding: even well-configured bot blocking doesn't reliably get you below roughly 30% non-human traffic on a large public site. The post covers Cloudflare Bot Fight Mode, rate limiting, user-agent heuristics, and honeypot traps. The honeypot trap section is the most practically useful — it's a method that scales without per-request compute cost. Source: &lt;a href="https://patronview.com/news/99-percent-of-my-website-traffic-is-bots/" rel="noopener noreferrer"&gt;patronview.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Mistral Shieldstral: 3B open-weights multimodal moderation model
&lt;/h2&gt;

&lt;p&gt;Score: 249, 62 comments on August 4. Mistral released Shieldstral, a 3-billion-parameter open-weight model for multimodal content moderation. The architectural pitch is that it runs alongside a generation model — classifying or filtering inputs at inference time rather than as a separate API call to a third party. Small moderation models running locally matter for pipelines where routing content to a classification API creates cost or privacy issues. I haven't tested it in my ETL — my content pipeline ingests public model metadata rather than user-generated content — but it would be directly relevant for anyone building a UGC product where third-party classification isn't viable. Source: &lt;a href="https://mistral.ai/news/shieldstral/" rel="noopener noreferrer"&gt;Mistral AI blog&lt;/a&gt;.&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>webdev</category>
    </item>
    <item>
      <title>What I learned building a pipeline health monitor that opens GitHub Issues automatically</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sun, 09 Aug 2026 06:36:30 +0000</pubDate>
      <link>https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl</link>
      <guid>https://dev.to/morinaga/what-i-learned-building-a-pipeline-health-monitor-that-opens-github-issues-automatically-fkl</guid>
      <description>&lt;p&gt;The trigger was a four-day gap I noticed in retrospect. The Bluesky queue had silently stalled because an API token expired mid-run, every daily workflow returned exit code 0, and nothing in GitHub Actions went red. The &lt;a href="https://dev.to/morinaga/how-i-built-a-pre-post-qc-gate-that-blocks-bluesky-automation-from-self-revealing-41ja"&gt;Bluesky QC gate&lt;/a&gt; was working correctly — it blocked a bad post, then the token expired before the next run, and after that the gate started rejecting everything because authentication was broken. Four days later I noticed the account had gone quiet.&lt;/p&gt;

&lt;p&gt;The conclusion I took from that: job status is not the same as output progress. I'd built the &lt;a href="https://dev.to/morinaga/i-built-3-programmatic-seo-sites-for-25month-using-claude-haiku-heres-the-full-architecture-3pl8"&gt;three-site content pipeline&lt;/a&gt; assuming those were equivalent. The assumption was wrong.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;scripts/pipeline-health.py&lt;/code&gt;, a Python script that runs every night in GitHub Actions at 23:30 UTC — after the day's content routines complete — and checks three separate signals. It uses the &lt;a href="https://docs.github.com/en/rest/actions/workflow-runs" rel="noopener noreferrer"&gt;GitHub Actions REST API&lt;/a&gt; and the &lt;a href="https://docs.github.com/en/rest/issues/issues" rel="noopener noreferrer"&gt;GitHub Issues API&lt;/a&gt; via &lt;code&gt;urllib.request&lt;/code&gt;, so there are no dependencies beyond the Python standard library and the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; that Actions injects automatically. When any signal fires, it opens a GitHub Issue with a structured diagnostic body. When everything clears, it closes the Issue automatically. The rest of the time, it's silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three failure modes and why job status misses two of them
&lt;/h2&gt;

&lt;p&gt;The script watches four content workflows by display name: &lt;code&gt;yt-publish&lt;/code&gt;, &lt;code&gt;Publish articles&lt;/code&gt;, &lt;code&gt;yt-publish-longform&lt;/code&gt;, and &lt;code&gt;Bluesky queue post&lt;/code&gt;. For each, it queries the GitHub Actions API for runs in the last 26 hours and reports any &lt;code&gt;failure&lt;/code&gt; or &lt;code&gt;cancelled&lt;/code&gt; conclusion. That catches the obvious failure: a job that crashed or was abandoned.&lt;/p&gt;

&lt;p&gt;The obvious failure is not the dangerous one.&lt;/p&gt;

&lt;p&gt;Signal 2 is no-progress detection. The YouTube publish workflow reads from a queue directory and exits 0 whether it published a video or found an empty queue — both cases look identical from CI. The script reads &lt;code&gt;content/yt-queue/uploaded/&lt;/code&gt; and checks the timestamp of the most recent file. If nothing in that directory is newer than 36 hours, video production has stalled even though no workflow failed. The same threshold applies to Bluesky: 36 hours without a logged post triggers the alert.&lt;/p&gt;

&lt;p&gt;Signal 3 is queue staleness. The Bluesky queue is a directory of pending posts, and the script checks whether the oldest file in that queue is more than 14 days old. A post sitting in the queue for 14 days almost certainly missed its window — either a QC gate blocked it permanently, or the queue processor is stuck on it. Neither is visible from job status.&lt;/p&gt;

&lt;p&gt;The three signals catch distinct failure classes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;What it misses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workflow failures&lt;/td&gt;
&lt;td&gt;Crashed jobs, timeouts, config errors&lt;/td&gt;
&lt;td&gt;Successful no-ops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-progress (36h)&lt;/td&gt;
&lt;td&gt;Stalled pipelines with clean CI&lt;/td&gt;
&lt;td&gt;Bad-but-published content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale queue (14d)&lt;/td&gt;
&lt;td&gt;Content backed up past its useful lifetime&lt;/td&gt;
&lt;td&gt;Short-term stalls under the threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gaps in coverage matter as much as the coverage itself. The health monitor doesn't read article content, doesn't verify publish quality, and doesn't know whether a video is good. The &lt;a href="https://dev.to/morinaga/how-i-built-a-content-quality-gate-that-stops-bad-articles-before-they-publish-p5c"&gt;content quality gate&lt;/a&gt; handles article-level problems; the &lt;a href="https://dev.to/morinaga/four-libsql-queries-i-use-to-catch-etl-gaps-in-my-ai-model-directory-34a0"&gt;ETL health queries&lt;/a&gt; catch data-level stalls. The pipeline monitor only asks whether something shipped at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a single deduplicated Issue beats individual notifications
&lt;/h2&gt;

&lt;p&gt;The first version opened a new Issue every time the watchdog detected a problem. Within two weeks there were 47 open Issues about variations of the same stalled pipeline. I stopped reading them.&lt;/p&gt;

&lt;p&gt;The second version writes to one Issue. The script embeds &lt;code&gt;&amp;lt;!-- pipeline-health-bot --&amp;gt;&lt;/code&gt; as a marker in the Issue body, searches for that marker on each run, and updates the existing Issue in place if found. No new Issue is opened while an alert is active; the body is replaced with the current diagnostic state and the timestamp of detection.&lt;/p&gt;

&lt;p&gt;When all three signals clear — no workflow failures, progress markers are recent, queue isn't stale — the script calls the GitHub Issues API to close the open alert Issue. The next failure opens a new one. The effect: the Issue tracker contains at most one &lt;code&gt;pipeline-health&lt;/code&gt; issue at any moment, and its open/closed state maps directly to pipeline health.&lt;/p&gt;

&lt;p&gt;The closed-issue history becomes a free audit trail. Filtering closed Issues by the &lt;code&gt;pipeline-health&lt;/code&gt; label shows exactly when the pipeline stalled and for how long, without any additional storage or logging infrastructure.&lt;/p&gt;

&lt;p&gt;I considered Slack or email notifications instead. The problem with both: they create a parallel notification stream I have to check separately from my normal work. A GitHub Issue surfaces in the same place I track everything else. It auto-assigns priority by being open. When it closes, I don't have to manually mark anything resolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation: stdlib only, no npm install, no external packages
&lt;/h2&gt;

&lt;p&gt;The script uses only Python's standard library: &lt;code&gt;urllib.request&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;os&lt;/code&gt;, &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;sys&lt;/code&gt;. No &lt;code&gt;requests&lt;/code&gt;, no &lt;code&gt;PyGithub&lt;/code&gt;, no install step.&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;_req&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;if&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;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/vnd.github+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&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-GitHub-Api-Version&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;2022-11-28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitHub Actions step needs two permissions: &lt;code&gt;actions: read&lt;/code&gt; to query workflow run history, and &lt;code&gt;issues: write&lt;/code&gt; to open and close the alert Issue. &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and &lt;code&gt;GITHUB_REPOSITORY&lt;/code&gt; are provided automatically in the Actions context — no additional secrets setup.&lt;/p&gt;

&lt;p&gt;The workflow runs once daily at 23:30 UTC, not on every push or every workflow completion. Running on a fixed schedule avoids double-alerting when a single stall causes multiple overlapping failures. The 30-minute offset after the top of the hour puts it after the nightly content jobs finish, which avoids flagging in-progress work.&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;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;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;23&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;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger lets me run it manually to check state without waiting for the nightly run. This was useful while debugging the no-progress thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I picked the thresholds
&lt;/h2&gt;

&lt;p&gt;The 36-hour no-progress threshold came from failure data. The shortest real stall that caused content gaps was about 30 hours. The &lt;a href="https://dev.to/morinaga/four-github-actions-cron-timing-bugs-that-silently-broke-my-daily-pipelines-4935"&gt;GitHub Actions cron timing bugs&lt;/a&gt; I'd hit previously mean the watchdog itself might fire off schedule. 36 hours gives one missed daily run plus a six-hour margin before it trips; 24 hours produced false positives in testing.&lt;/p&gt;

&lt;p&gt;The 14-day queue staleness threshold mirrors &lt;code&gt;QUEUE_MAX_AGE_DAYS&lt;/code&gt; in &lt;code&gt;yt-publish.yml&lt;/code&gt;. The YouTube publisher drops files older than 14 days rather than publish them out of context. A file that old in the queue is one the publisher won't touch — it signals a stall that's gone past the point of self-repair.&lt;/p&gt;

&lt;p&gt;The 26-hour window for checking workflow runs is slightly longer than a calendar day. This prevents a gap when the nightly run schedule drifts by a few minutes and a failure from the previous night's run falls just outside a 24-hour window.&lt;/p&gt;

&lt;p&gt;I changed these thresholds once after the initial deployment: the Bluesky no-progress threshold started at 24 hours and produced a false alert after I intentionally paused posting for a day without updating the configuration. 36 hours now gives me room to pause for a day without triggering an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed after I had this running
&lt;/h2&gt;

&lt;p&gt;The monitoring changed how I think about the &lt;a href="https://dev.to/morinaga/five-changes-i-made-after-exhausting-github-actions-free-minutes-twice-5c88"&gt;GitHub Actions free quota&lt;/a&gt;. When I was cutting workflow runs to stay under the free tier ceiling, I evaluated each workflow by its compute cost. The pipeline health monitor runs for about 20 seconds and queries the API a handful of times — nearly free in compute terms. But its value is asymmetric: 20 seconds of Actions minutes catches failures that would otherwise take me days to notice. I kept it without hesitation.&lt;/p&gt;

&lt;p&gt;It also surfaced a pattern I hadn't noticed: the &lt;a href="https://dev.to/morinaga/how-i-schedule-three-daily-bluesky-posts-from-a-jsonl-queue-without-an-external-service-mno"&gt;Bluesky JSONL queue&lt;/a&gt; stalls more often than the YouTube queue, and almost always from auth issues rather than content issues. The no-progress signal has fired three times total. Two of those three were Bluesky auth failures; one was an unrelated disk quota issue in the upload step. That distribution told me where to add better error handling, which the monitoring made visible.&lt;/p&gt;

&lt;p&gt;The monitor doesn't eliminate production failures. It shrinks the detection window from "whenever I notice the feed went quiet" to "the morning after the failure occurred."&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't catch
&lt;/h2&gt;

&lt;p&gt;Bad-but-published content. If a YouTube video uploads with corrupted audio, or a Bluesky post goes out with a broken image, the pipeline-health monitor doesn't know. It only asks whether something was published, not whether what was published was correct. That's a separate concern that belongs in the &lt;a href="https://dev.to/morinaga/how-i-built-a-pre-post-qc-gate-that-blocks-bluesky-automation-from-self-revealing-41ja"&gt;QC gates&lt;/a&gt; before publish, not in a post-hoc health check.&lt;/p&gt;

&lt;p&gt;Cascade failures where the pipeline restarts fast enough that no 36-hour window opens. A job that fails and restarts within an hour — GitHub's built-in retry behavior — looks healthy from the no-progress signal even if it failed and retried five times.&lt;/p&gt;

&lt;p&gt;Intentional pauses. If I decide to take a break from publishing, the monitor trips after 36 hours. I handle this by closing the alert Issue manually and ignoring it for the pause duration, then re-opening if things haven't resumed. There's a cleaner solution — a &lt;code&gt;PAUSED_UNTIL&lt;/code&gt; environment variable the script could check — but I've hit this scenario infrequently enough that I haven't implemented it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this replace checking the GitHub Actions UI directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The UI shows job-level status and step logs, which the health monitor doesn't replicate. This adds a layer on top: watching outputs rather than jobs, and creating a persistent artifact (the Issue) that survives past the run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Python instead of a Node.js script like the other monitoring tools in this project?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No strong reason. I wrote it during a session already in Python and &lt;code&gt;urllib.request&lt;/code&gt; handles everything without an npm install step. A Node.js version would work equally well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the &lt;code&gt;QUEUE_MAX_AGE_DAYS&lt;/code&gt; threshold based on?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It mirrors the hard cap in the publisher: the queue processor drops files older than 14 days rather than publish stale content. A file that's been in the queue past that threshold is one the publisher won't handle automatically — the stall is permanent until a human intervenes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could GitHub's built-in failure notifications replace this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GitHub can notify on workflow job failures. It can't detect no-progress signals or queue staleness. Those two signals are specific to my pipeline's output semantics and require code that understands what "progress" means in this context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why auto-close the Issue instead of requiring manual review?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open issues accumulate and lose signal value. A closed issue with the self-resolution timestamp is more useful for retrospective analysis than an issue that requires someone to manually close it after confirming everything's working. If the pipeline is healthy, there's no issue to read; if it's not, there's exactly one issue that says what's 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>githubactions</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Notable this week: NOOA, Prime Agent, Qwen3.8-Max, DeepSeek V4-Flash official</title>
      <dc:creator>MORINAGA</dc:creator>
      <pubDate>Sun, 09 Aug 2026 06:36:25 +0000</pubDate>
      <link>https://dev.to/morinaga/notable-this-week-nooa-prime-agent-qwen38-max-deepseek-v4-flash-official-201n</link>
      <guid>https://dev.to/morinaga/notable-this-week-nooa-prime-agent-qwen38-max-deepseek-v4-flash-official-201n</guid>
      <description>&lt;p&gt;Five things I bookmarked this week. Each one covers a different angle — agent frameworks, frontier models, and tooling — so there's some breadth here.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. NVIDIA NOOA — agents as plain Python classes
&lt;/h2&gt;

&lt;p&gt;NVIDIA Labs released NOOA (NVIDIA Object-Oriented Agents) on August 7 under Apache 2.0. The core design decision: an agent is a single Python class, not a YAML config or a JSON scaffold. Six harness capabilities — typed I/O, pass-by-reference, code as action, programmable loops, explicit state, and model-callable APIs — drive the design. LiteLLM handles model routing, so the same agent code runs against Claude, GPT-5, Ollama, or a local vLLM endpoint without changes. Install is &lt;code&gt;pip install nooa&lt;/code&gt; (v0.0.8, alpha, Python 3.12–3.13).&lt;/p&gt;

&lt;p&gt;The number they lead with — 82.2% on SWE-bench Verified using GPT-5.5, with half the token budget of prior state-of-the-art — is worth paying attention to. I'm more interested in whether the object-oriented model holds up outside coding tasks, where SWE-bench stops being useful signal. That's the question I'll be watching through the rest of the year. Source: &lt;a href="https://www.marktechpost.com/2026/08/07/nvidia-ai-releases-nooa-an-object-oriented-python-framework/" rel="noopener noreferrer"&gt;MarkTechPost&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. PrimeIntellect/prime-agent — self-improving RLM harness
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/PrimeIntellect-ai/prime-agent" rel="noopener noreferrer"&gt;Prime Agent&lt;/a&gt; dropped August 5, MIT license. The architecture is unusual enough to read past the headline. Rather than a tool-call loop, the agent runs inside a persistent IPython kernel. Context is a variable the agent actively manages; memory, skill definitions, and subagent specs are durable state it can update (CRUD) from within its own trajectory. They call this the Recursive Language Model (RLM) abstraction.&lt;/p&gt;

&lt;p&gt;With Claude Opus 5, they report 95.5% on ARC-AGI-3, fractionally above the 95.4% human expert baseline. I'm generally cautious about single-benchmark numbers, but the architectural difference from ReAct-style agents is real enough that the technical report is worth reading rather than skimming. The repo is early-stage; the GitHub star count has been climbing fast since the August 5 announcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Qwen3.8-Max — 2.4T parameter MoE from Alibaba
&lt;/h2&gt;

&lt;p&gt;Alibaba published Qwen3.8-Max on August 3 (&lt;a href="https://www.marktechpost.com/2026/08/03/alibaba-qwen-releases-qwen3-8-max/" rel="noopener noreferrer"&gt;MarkTechPost&lt;/a&gt;). Architecture is MoE: 2.4 trillion parameters total, 95 billion active per token. Context window is 1 million tokens. Native multimodal support is included.&lt;/p&gt;

&lt;p&gt;Alibaba is pricing international API access at roughly 40% of Claude Opus 5 input and 24% output — aggressive positioning. Benchmark positions on launch: fifth Text Arena, second Vision Arena. Open weights are scheduled for release this week; I'll form a more concrete view once I can run it locally rather than through the API. The pricing point means it will probably land in my comparison ETL on &lt;a href="https://aiappdex.com" rel="noopener noreferrer"&gt;aiappdex.com&lt;/a&gt; — I've been adding new frontier models to the pairwise compare pages as weights become available.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. DeepSeek V4-Flash 0731 — official GA with re-post-trained agent checkpoint
&lt;/h2&gt;

&lt;p&gt;DeepSeek moved V4-Flash from preview to official general availability on July 31 (&lt;a href="https://www.marktechpost.com/2026/07/31/deepseek-upgrades-deepseek-v4-flash-0731-with-major-agentic-and-coding-gains/" rel="noopener noreferrer"&gt;MarkTechPost&lt;/a&gt;). Same 284B CSA+HCA backbone as the preview checkpoint; what changed is a re-post-training pass focused on agentic tasks. DeepSeek reports official V4-Flash beating V4-Pro-Preview across nine agent benchmarks. The model is MIT-licensed on Hugging Face and the API is at $0.14/$0.28 per 1M input/output tokens.&lt;/p&gt;

&lt;p&gt;I switched my comparison ETL to the preview three weeks ago and the official checkpoint runs noticeably cleaner on instruction-following edge cases — specifically the cases where the preview would produce partial JSON before hitting a stop sequence. That single improvement is enough to keep it in my pipeline for the foreseeable future. The price point also makes it viable for high-volume extraction tasks where using a flagship model would be economically absurd.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. addyosmani/agent-skills — 76k GitHub stars, production skills for coding agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/addyosmani/agent-skills" rel="noopener noreferrer"&gt;agent-skills&lt;/a&gt; by Addy Osmani (Engineering Lead at Google) crossed 76k GitHub stars this week and has been one of the fastest-growing developer tool repos of 2026. Twenty-four skills in plain Markdown — security review, migration strategy, testing discipline — compatible with Claude Code, Cursor, GitHub Copilot, and Codex. Install: &lt;code&gt;npx skills add addyosmani/agent-skills&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What distinguishes it from other prompt template repos: the skills are specific about what to check, not vague about intent. Rather than "be thorough," the security-review skill names concrete vulnerability classes. I've been incorporating a few of the patterns into how I prompt Claude Code in my own pipeline. Worth reading even if you don't use the installer — the framing is useful standalone.&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>
  </channel>
</rss>
